ui: improve HTML inline layout and old Reddit rendering

- implement per-side CSS border styles and patterned rendering
  - preserve rectangular rasterization for thin dotted borders
  - separate CSS and native-widget border defaults
  - correct inline atomic-box and vertical-align baseline layout
  - collapse adjacent block margins in rich-text flow
  - preserve forced line breaks and formatting margins
  - size HTML text inputs from font ascent and descent
  - expose replaced-control text baselines to inline layout
  - enforce single-owner painting for inline backgrounds and borders
  - enable and update the old Reddit visual regression golden
  - add generic layout, border, and form-control regression tests
  - document spec-first HTML work and project build conventions
This commit is contained in:
Martín Lucas Golini
2026-08-02 17:48:53 -03:00
parent 2df0861d76
commit 48b7e7c21f
32 changed files with 1344 additions and 1455 deletions
@@ -0,0 +1,724 @@
# eepp Linux Build-Time Optimization Plan
Status: **Planning complete; no implementation or benchmark run has started.**
Last review: 2026-08-02.
## 1. Objective
Reduce clean and incremental Linux compilation time for eepp and ecode without lowering the
current optimization level and without degrading runtime performance, memory use, API quality,
or supported platforms.
The work must be measurement-driven. Do not perform broad include removal, container replacement,
PImpl conversion, unity builds, or precompiled-header adoption without showing which measured cost
the change addresses and whether it improves the relevant end-to-end build.
The primary machine for the initial investigation is:
```text
AMD Ryzen 9 3900X
12 cores / 24 hardware threads
Linux
Clang 22.1.8
Ninja
```
The repository supports Clang time tracing in both generators: `premake4 --time-trace` and
`premake5 --time-trace` both add `-ftime-trace` to compile commands. They serve different normal
workflows in this repository:
- **debug and unit-test builds use Premake 4 with the GNU Make generator**, following
`.agent/rules/build-project.md`;
- **release build-time and runtime-performance investigations use Premake 5 with the Ninja
generator**, following the `eepp-linux-ninja` configuration in `.ecode/project_build.json`.
Do not substitute one workflow for the other merely because both generators expose
`--time-trace`. Use Premake 5/Ninja for the primary release compilation-time baseline and Premake
4/GNU Make when specifically measuring or validating the normal debug workflow.
## 2. Scope and non-goals
### In scope
- eepp library C++ translation units;
- ecode C++ translation units;
- eepp modules and tools when they are part of a measured developer workflow;
- public and private header fan-out;
- template parsing and instantiation cost;
- large generated language-syntax translation units;
- clean builds and representative incremental rebuilds;
- compiler-cache integration for normal developer builds;
- selective precompiled headers or unity builds if measurement justifies them;
- link-time measurement, while keeping compilation and linking results separate.
### Out of scope unless evidence changes the decision
- changing release or debug optimization levels;
- optimizing unchanged third-party C libraries;
- rewriting HarfBuzz or maintaining a private HarfBuzz fork;
- treating already up-to-date object files as a recurring build cost;
- replacing containers based only on header size or reputation;
- runtime-performance regressions in exchange for faster compilation;
- global PImpl conversion or other ABI-wide redesign;
- C++20 modules as an initial solution;
- using an AddressSanitizer build as the performance baseline.
Third-party sources are already handled correctly by incremental dependency tracking: unchanged
objects are not rebuilt. Most third-party units are C and compile quickly. HarfBuzz is a known
heavy dependency, but is considered residual cost rather than an optimization target. Report its
time separately so it does not obscure improvements to project-owned code.
## 3. Current evidence and hypotheses
At the time this plan was written:
- `make/linux/release_x86_64/compile_commands.json` contained 1,989 commands: 1,283 C++ and 706 C;
- generated commands invoked `clang` / `clang++` directly rather than consistently using ccache or
sccache;
- ccache, sccache, mold, and Ninja were installed on the machine;
- no project PCH or unity-build configuration was found;
- historical `.ninja_log` entries showed expensive project-owned objects including `ecode.o`,
`chatui.o`, `lspclientserver.o`, several ecode plugin objects,
`stylesheetspecification.o`, `uicodeeditor.o`, `uihtml_tests.o`, and generated language syntax
objects;
- `syntaxdefinitionmanager.hpp` was directly included by at least 169 C++ sources;
- large foundational public headers included `scene/node.hpp`, `ui/uinode.hpp`, `ui/uiwidget.hpp`,
`core/string.hpp`, `ui/uiscenenode.hpp`, `ui/uicodeeditor.hpp`, `network/http.hpp`, and
`ui/doc/textdocument.hpp`.
These observations identify candidates, not conclusions. Ninja durations from a parallel build
include CPU and memory contention and must not be treated as isolated TU compile times.
The leading hypotheses are:
1. generated syntax-definition units repeatedly parse more manager/UI infrastructure than needed;
2. large ecode and UI source files receive expensive transitive dependency trees;
3. implementation-only types are exposed through common headers;
4. substantial inline or template code is instantiated repeatedly;
5. developer rebuilds are missing avoidable compiler-cache hits;
6. a carefully scoped PCH may reduce repeated standard-library and stable framework parsing;
7. selective unity grouping may help stable, homogeneous source families, but could hurt
incremental builds and peak memory.
## 4. Required operating rules
Before any work, read:
```text
.agent/SOUL.md
.agent/rules/project-introduction.md
.agent/rules/build-project.md
.ecode/project_build.json
```
Also follow these rules:
1. Run all Premake and build commands from the repository root unless the command explicitly uses
`-C make/linux`.
2. Record `git status --short` before touching files. Preserve all pre-existing user changes.
3. Do not commit, push, reset, or discard user work.
4. Regenerate project files after source or Premake changes, as required by the build rules.
5. Format every modified C/C++ file with `clang-format` before compilation.
6. Keep benchmark artifacts outside tracked source directories, preferably under
`/tmp/eepp-build-time-<date>/`.
7. Do not mix ASan results with build-time baseline results.
8. Do not compare runs generated with different compilers, flags, backends, target sets, cache
modes, or background load.
9. Repeat important measurements at least three times and report the median. If variance exceeds
5%, investigate noise before claiming a small improvement.
10. Separate clean-build, no-op, incremental, cache-hit, and isolated-TU results.
11. Preserve `-O3` for release experiments. Build-time changes must not come from reducing
optimization.
12. After every structural C++ change, run an allocation and runtime-performance audit as required
by `.agent/SOUL.md`.
13. Preserve the repository's generator split: Premake 4/GNU Make for normal debug and unit-test
workflows, and Premake 5/Ninja for release build-time investigations. Do not compare their
timings as though they were the same build configuration.
## 5. Benchmark matrix
Do not use a single `ninja release` duration as the only metric. Establish the following named
workflows.
| ID | Workflow | Purpose |
|---|---|---|
| B1 | clean eepp-owned library build | framework clean-build cost |
| B2 | clean ecode build including required dependencies | real application clean-build cost |
| B3 | no-op repeat of B2 | generator/dependency overhead sanity check |
| B4 | edit/touch one leaf `.cpp`, rebuild ecode | common local edit latency |
| B5 | touch a widely used eepp core header, rebuild | public-header blast radius |
| B6 | touch a widely used UI header, rebuild | UI dependency blast radius |
| B7 | touch syntax-definition interface, rebuild | generated syntax fan-out |
| B8 | isolated compilation of each top slow C++ TU | remove parallel contention |
| B9 | cache-enabled rebuild after removing only selected outputs | compiler-cache benefit |
| B10 | link-only relink | keep link cost separate from compile cost |
| B11 | normal Premake 4/GNU Make debug build and representative incremental rebuild | ensure improvements also help or at least do not harm the debug workflow |
The executing agent must first inspect `make/linux/build.ninja` with `ninja -t targets` and identify
the exact target names for eepp, ecode, and their configurations. Do not guess target names. Store
the resolved commands in the benchmark report.
For header invalidation tests, prefer `touch` followed by restoring the original timestamp if that
can be done safely, or make a reversible whitespace change in a clean file. Never overwrite user
changes. Record the exact header and why it represents the workflow.
## 6. Phase 0: Prepare the investigation
### Tasks
1. Capture repository state and tool versions:
```bash
git status --short
premake4 --version
premake5 --version
clang --version
clang++ --version
ninja --version
ccache --version
sccache --version
mold --version
nproc
lscpu
```
2. Re-read `.ecode/project_build.json` and use `eepp-linux-ninja` as the source of truth.
3. Inspect available Ninja targets:
```bash
ninja -C make/linux -t targets all
```
4. Inspect representative compile commands and confirm:
- compiler;
- `-O3` in release;
- debug-symbol setting;
- SDL backend;
- architecture;
- whether ccache/sccache is actually in the command;
- whether the target compiles only required dependencies or the whole workspace.
5. Confirm both time-trace options remain defined before relying on them:
```bash
premake4 --help | rg 'time-trace'
premake5 --help | rg 'time-trace'
```
6. Create an untracked results directory under `/tmp`, containing:
- `environment.txt`;
- `commands.txt`;
- `baseline.tsv`;
- `traces/`;
- `reports/`.
### Exit criteria
- Exact target names are known.
- Benchmark commands are reproducible.
- Existing user changes are documented and protected.
- No source code has changed.
## 7. Phase 1: Establish baselines
### Build generation
Use the current non-ASan Premake 5/Ninja configuration for the primary release baseline:
```bash
premake5 --disable-static-build --with-debug-symbols --with-backend=SDL3 ninja
```
If `.ecode/project_build.json` changes before execution, follow the updated configuration instead
and document the difference.
The normal debug workflow is separate. Generate it with Premake 4 and GNU Make, using the exact
current command prescribed by `.agent/rules/build-project.md` and including the conditional mold
flag when required. This debug build may use AddressSanitizer because that is the project's normal
debug/test configuration, but never use its timings as release-performance measurements or compare
them directly with the Premake 5/Ninja release baseline.
### Timing method
Use `/usr/bin/time` so wall time, CPU time, and maximum resident set size are captured. A template
is:
```bash
/usr/bin/time -f 'wall=%e user=%U sys=%S cpu=%P maxrss_kb=%M exit=%x' \
ninja -C make/linux <resolved-target>
```
Run each meaningful baseline three times under the same conditions. For clean builds, use the
narrowest safe Ninja clean operation for the measured target/configuration. Inspect the clean
command before running it; do not delete the repository or broad directories manually.
Record:
- run ID and timestamp;
- exact generation and build command;
- cold/warm filesystem-cache state, without forcibly dropping kernel caches;
- compiler-cache enabled/disabled state;
- wall, user, system, CPU%, and peak RSS;
- number of commands executed;
- target result size and link duration where available;
- relevant system load and CPU frequency governor.
Do not use `ninja -d stats` output as a replacement for wall-clock timing, but capture it where
useful.
### Analyze historical Ninja data
Use `.ninja_log` only as a candidate generator. Since it can contain repeated historical entries,
group by output and report the latest run or distribution rather than blindly taking the maximum.
Suggested extraction starting point:
```bash
awk 'NR > 1 && $2 >= $1 { print $2-$1, $4 }' make/linux/.ninja_log | sort -nr
```
Classify entries as:
- project-owned eepp;
- project-owned ecode/tools/modules/tests;
- generated syntax definitions;
- HarfBuzz;
- other third-party C/C++;
- linking.
### Exit criteria
- B1 through B7 and B11 have commands and initial measurements, with debug and release results
kept separate.
- No-op B3 executes zero unexpected compiler commands.
- Third-party and link costs are separated from project-owned compilation.
- Variance is understood well enough to evaluate later changes.
## 8. Phase 2: Capture and aggregate Clang time traces
### Generate traced build files
Regenerate the primary release trace build with Premake 5 while preserving all baseline options:
```bash
premake5 --disable-static-build --with-debug-symbols --with-backend=SDL3 --time-trace ninja
```
Confirm a representative C++ compile command contains both `-O3` and `-ftime-trace`.
If tracing the debug workflow as a separate investigation, add `--time-trace` to the current
Premake 4 debug generation command from `.agent/rules/build-project.md`, then build with GNU Make.
Keep those trace reports in a separate `debug-premake4/` results directory. Debug/ASan traces may
identify dependency fan-out, but their total durations and optimization/backend costs are not
comparable to the Premake 5 release traces.
Run a clean, scoped traced build. Trace instrumentation adds overhead, so do not compare traced
wall time directly with the untraced baseline. Its purpose is attribution.
Before compiling, determine where this Clang version writes trace JSON files. Copy them to the
temporary results directory after the build while retaining a mapping from trace to source/object.
Do not add trace JSON files to Git.
### Aggregate these categories
For every C++ trace, extract at least:
- total compiler duration;
- frontend duration;
- backend/optimizer duration;
- source/header parsing totals;
- template instantiation totals;
- code generation totals;
- expensive individual headers;
- expensive template specializations where Clang names them.
Produce these reports:
```text
reports/tu-total.tsv
reports/frontend.tsv
reports/backend.tsv
reports/header-self.tsv
reports/header-cumulative.tsv
reports/template-instantiation.tsv
reports/project-vs-third-party.tsv
```
If no existing repository tool aggregates traces adequately, create a small standalone analysis
script under the temporary results directory first. Only add a reusable script to the repository
later if it proves valuable. The parser must stream or process files one at a time rather than load
all trace files into memory simultaneously.
### Required ranking method
Rank headers by both:
1. expensive appearance in one TU;
2. cumulative cost across all project-owned TUs.
Also record include fan-out. A 100 ms header parsed in 200 units is usually more valuable than a
one-second header parsed once.
### Isolated TU confirmation
For the top 10–20 project-owned C++ objects, extract the exact compile command from
`compile_commands.json` or Ninja and execute it serially with `/usr/bin/time`. Preserve its output
path safely or direct experimental output into `/tmp`; do not corrupt normal build dependencies.
Run each top candidate at least three times. This distinguishes intrinsic cost from contention in
the original parallel build.
### Exit criteria
- At least 80% of project-owned C++ compilation time is classified by subsystem or candidate.
- Top headers are ranked by cumulative cost and fan-out.
- Top slow TUs have isolated measurements.
- Frontend-heavy and backend-heavy candidates are separated.
## 9. Phase 3: Header dependency and syntax-definition investigation
This is the first source-level optimization phase.
### 9.1 Generated syntax definitions
Start here if traces confirm the current hypothesis.
Inspect the generated language source family and answer:
- Why does each source include `syntaxdefinitionmanager.hpp`?
- Does registration require the full manager definition?
- Can definition construction use a small declaration/value header?
- Can manager registration move to one aggregation `.cpp`?
- Are large initializer expressions causing frontend or backend cost?
- Are identical template specializations emitted repeatedly?
- Can data be expressed in a representation that compiles faster without adding startup work,
heap churn, or runtime parsing?
Preferred low-risk direction:
```text
small syntax-definition declaration/data interface
-> individual generated language units
full manager implementation
-> one or a few aggregation/registration units
```
Do not merge all languages into one huge source unless an isolated experiment shows acceptable
incremental behavior and memory use.
### 9.2 High cumulative-cost headers
For every top header, use preprocessing/include-tree tools to identify why it is present. Useful
commands include the exact compile command augmented with one of:
```text
-H
-E
-ftime-trace
```
Investigate:
- includes needed only by `.cpp` implementation;
- pointer/reference members that can use forward declarations;
- inline functions whose definitions require heavy dependencies;
- nested type references that force full includes;
- callbacks using `std::function` in ubiquitous public APIs;
- private concrete containers exposed in class layout;
- umbrella/convenience headers included by lower layers;
- templates that can be explicitly instantiated;
- duplicated helper templates or traits.
### Change rules
Make one logical dependency change per benchmarkable patch. For each change:
1. record the baseline candidate metrics;
2. implement the smallest correction;
3. regenerate build files;
4. rebuild the affected target;
5. run relevant tests;
6. repeat isolated TU measurement;
7. repeat the relevant clean/incremental benchmark;
8. record runtime/allocation/API consequences;
9. revert changes that do not produce a repeatable useful gain.
### PImpl warning
Do not introduce PImpl solely to hide includes when it adds per-object allocation or indirection to
hot eepp types. Prefer, in order:
1. forward declaration without layout changes;
2. moving out-of-line function bodies;
3. small non-owning interface types;
4. splitting stable data from heavy behavior;
5. PImpl only for cold, coarse-grained objects where allocation and ABI tradeoffs are acceptable.
### Exit criteria
- At least the top five cumulative project header costs have been explained.
- The syntax-definition hypothesis has either produced a measured improvement or been rejected
with evidence.
- Accepted changes improve B1/B2/B5/B6/B7 as applicable, not merely preprocessing byte count.
- All affected tests pass.
## 10. Phase 4: Template and container cost
Only begin this phase if time traces show meaningful template parsing or instantiation cost.
### Investigate first
- which exact templates dominate;
- number of unique versus repeated specializations;
- whether cost is parsing, instantiation, optimization, or debug information;
- whether the specialization is required in public headers;
- whether explicit instantiation is legal and useful;
- whether a non-template interface boundary would preserve runtime performance.
### Candidate techniques
- `extern template` declarations plus explicit instantiation in one `.cpp`;
- moving template-heavy operations out of common headers;
- reducing accidental type variation that creates near-duplicate specializations;
- replacing a container only when both build-time traces and runtime requirements support it;
- using spans/views at API boundaries to avoid exporting container implementation choices.
Do not assume eepp's `UnorderedMap` or `UnorderedSet` is compile-time cheaper merely because it is
runtime-preferred. `include/eepp/thirdparty/unordered_dense.h` is itself substantial. Compare small
representative compilations and affected end-to-end targets before changing types.
### Acceptance gate
A container/template change is accepted only if:
- it improves a named build metric beyond noise;
- runtime benchmarks do not regress materially;
- memory/allocation behavior is equal or better, or a tradeoff is explicitly approved;
- public API and serialization behavior remain correct;
- cross-platform compilers remain supported.
## 11. Phase 5: Compiler-cache integration
This phase improves developer workflow but must be reported separately from structural cold-build
improvements.
### Procedure
1. Inspect how Premake's Ninja generator selects `CC` and `CXX`.
2. Prototype ccache first because the machine already has it and it is straightforward locally.
3. Prefer a compiler launcher or generated-command prefix over replacing the compiler identity in
a way that breaks dependency generation.
4. Confirm commands actually invoke ccache.
5. Clear only the experimental cache namespace when a cold-cache test is required; do not erase a
user's global cache without explicit permission.
6. Record `ccache -z`, run the workflow, then record `ccache -s`.
7. Test:
- empty-cache clean build;
- immediate rebuild after removing selected objects;
- rebuild after a source edit and revert;
- rebuild after switching between debug and release;
- cache invalidation after a common header changes.
8. Compare cache overhead and hit rate.
Evaluate sccache only if remote/shared caching or its operational model is desired. Do not enable
both simultaneously.
### Acceptance gate
- no dependency correctness regressions;
- no stale-object behavior;
- measurable warm-build benefit;
- negligible cold-cache regression;
- documented opt-in/default policy;
- structural benchmark results remain available with cache disabled.
## 12. Phase 6: Selective precompiled-header experiment
Attempt PCH only after trace aggregation identifies a stable common header prefix.
### Candidate selection
A PCH candidate should be:
- parsed by many TUs;
- expensive cumulatively;
- stable across ordinary edits;
- compatible across all commands in the target group;
- mostly standard-library or stable project configuration headers;
- free of order-dependent macros and per-TU configuration.
Do not place frequently edited eepp UI headers into the first PCH.
Prototype separate PCH scopes where appropriate:
- eepp core/library;
- ecode;
- generated syntax definitions.
### Measure
- clean target build;
- leaf `.cpp` incremental build;
- common-header invalidation rebuild;
- PCH-generation time;
- peak memory at `-j24`, `-j12`, and one lower concurrency if memory pressure appears;
- binary output and runtime parity.
### Acceptance gate
Keep a PCH only when end-to-end benefit remains significant after including PCH generation and its
invalidation cost. Avoid a PCH that improves clean builds but makes the dominant edit/rebuild loop
worse.
## 13. Phase 7: Selective unity-build experiment
Unity builds are optional and lower priority than dependency hygiene and PCH.
Good initial candidates are homogeneous, stable source families with shared includes, especially
generated syntax definitions if Phase 3 shows repeated frontend cost.
Do not begin with a monolithic eepp or ecode unity file.
### Required checks
- static/anonymous namespace symbol collisions;
- macro leakage and include-order dependence;
- warning changes;
- peak compiler memory;
- loss of parallelism;
- incremental rebuild amplification;
- debug experience and source attribution;
- generated-code update behavior.
Test multiple group sizes rather than only on/off. Compare clean time and leaf-edit latency at
realistic parallelism.
### Acceptance gate
Unity mode should be optional unless it improves both the dominant developer workflow and clean
builds without unacceptable memory, diagnostics, or incremental penalties.
## 14. Phase 8: Parallelism and linker tuning
This phase does not change optimization levels and should be done after source improvements.
### Parallelism sweep
Run the chosen clean target with at least:
```text
-j8
-j12
-j16
-j20
-j24
```
Measure wall time and peak RSS. The fastest setting on a 3900X may be below 24 because large Clang
jobs compete for memory bandwidth and cache. Recommend the best default separately for clean and
incremental builds if they differ.
### Linker
Measure mold versus the current linker only for B10 and full target wall time. Linker selection
does not explain compilation hotspots. If mold is already active, simply document the residual
link fraction.
## 15. Validation after every accepted code change
For C/C++ edits:
```bash
git diff --name-only -- '*.c' '*.cpp' '*.h' '*.hpp' | xargs clang-format -i
```
Regenerate the release build using the current approved Premake 5/Ninja command and compile the
narrow affected target. Regenerate debug/tests using the current Premake 4/GNU Make command, then
run focused unit tests followed by the full relevant unit-test suite when the phase is ready.
At minimum verify:
- clean build succeeds;
- incremental dependencies rebuild everything required and nothing obviously unrelated;
- debug and release configurations compile when the change affects shared build logic;
- no warnings were introduced;
- unit tests pass;
- public headers remain self-contained where expected;
- binary behavior and runtime performance remain unchanged;
- allocation audit is complete;
- `git diff --check` passes.
For Premake changes, inspect generated commands rather than assuming the intended flag or launcher
was applied.
## 16. Reporting format
Maintain one result table for accepted and rejected experiments:
| Experiment | Metric | Before median | After median | Delta | Variance | Decision |
|---|---|---:|---:|---:|---:|---|
| example | B2 wall | 100.0 s | 91.0 s | -9.0% | 1.2% | accept |
Each experiment report must include:
- hypothesis;
- exact files changed;
- exact commands;
- machine/environment differences;
- affected trace categories;
- clean and incremental results;
- peak-memory result;
- tests run;
- runtime/allocation considerations;
- decision and rationale.
Track improvements cumulatively, but periodically rerun the original baseline command from the
same branch state to detect benchmark drift.
## 17. Final deliverables
The executing agent should produce:
1. a reproducible benchmark script or documented command set;
2. baseline results for B1 through B11 where applicable, with Premake 4 debug and Premake 5 release
results clearly separated;
3. aggregated Clang trace reports;
4. a ranked list of project-owned TU, header, and template costs;
5. accepted source/build-system improvements, each independently measured;
6. a list of rejected experiments and why they failed;
7. recommended cache configuration for normal development;
8. recommended Ninja job count for the 3900X;
9. final clean-build and incremental-build comparison;
10. remaining known costs, including HarfBuzz, clearly separated from actionable eepp costs.
## 18. Execution order and stop conditions
Execute in this order:
```text
Phase 0 environment and targets
-> Phase 1 reproducible baselines
-> Phase 2 trace aggregation and isolated confirmation
-> Phase 3 dependency/syntax refactoring
-> Phase 4 template/container work if justified
-> Phase 5 compiler cache
-> Phase 6 selective PCH if justified
-> Phase 7 selective unity if justified
-> Phase 8 parallelism/link tuning
-> final validation and report
```
Stop or request guidance when:
- existing user changes overlap a required file and cannot be preserved safely;
- the active build configuration differs materially from this plan;
- a proposed change adds runtime allocation or indirection to a hot type;
- public API/ABI changes appear necessary;
- a measurement cannot be reproduced within reasonable variance;
- a change improves one workflow but materially harms a more important workflow;
- completing an experiment would require destructive cache or build-directory deletion not
explicitly authorized.
Do not declare success based on trace reduction alone. Success means repeatably lower wall time in
the named developer workflows, with correct builds and no unacceptable runtime or maintenance
cost.
@@ -1,398 +0,0 @@
# Old Reddit Thread Rendering Plan
## Objective
Render `bin/unit_tests/assets/html/reddit_old_thread.html` close to the Chrome reference image for a 1024px old Reddit comments page. The fixture is intentionally large enough to exercise real old Reddit layout patterns while still being local and deterministic.
The target is not a Reddit-specific hack. Each fix must move the HTML/CSS engine closer to the relevant CSS behavior and be covered by reduced tests before relying on the full-page screenshot.
## Current Fixture And Harness
- Fixture: `bin/unit_tests/assets/html/reddit_old_thread.html`
- Local assets:
- `bin/unit_tests/assets/html/reddit_old_thread_files/reddit.ETA_etA2z5U.css`
- `bin/unit_tests/assets/html/reddit_old_thread_files/sprite-reddit.13AvZYXRW_4.png`
- `bin/unit_tests/assets/html/reddit_old_thread_files/pixel.png`
- Reference image: `bin/unit_tests/assets/html/reddit_old_thread_reference_image.png`
- Smoke test: `UIHTML.redditOldThreadWebViewSmoke`
The smoke test opens the old Reddit fixture through `UIWebView` without loading the app theme. Browser-like HTML defaults are supplied by the HTML base defaults stylesheet when HTML widgets enter the node tree, so the test exercises the same default-style path used by real HTML content. It asserts the important page regions exist and emits `assets/html/eepp-reddit-old-thread-current.webp` through the existing image comparison helper.
The smoke test is opt-in because the full fixture is slow in ASAN:
```sh
EEPP_REDDIT_OLD_THREAD_VISUAL=1 projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTML.redditOldThreadWebViewSmoke"
```
It currently writes the visual artifact to `bin/unit_tests/output/eepp-reddit-old-thread-current.webp`. This is not a golden comparison yet. Replace or extend it with a true reference comparison only after the high-priority layout issues are fixed enough for pixel diffs to be useful.
Current progress:
- `UIHTMLFloat.leftFloatOverflowHiddenBlockFormattingContextSitsBesideFloat` covers the old Reddit `.midcol` + `.entry { overflow:hidden }` pattern.
- `overflow` values other than `visible` are tracked as block-formatting-context semantics on `UIHTMLWidget`.
- RichText atomic block metadata now distinguishes normal block boxes from block formatting contexts.
- `.entry` now starts to the right of `.midcol` in the fixture (`entry.x=44`, `midcol.x=15`, `midcol.width=19`).
- `UIHTMLFloat.rightFloatConstrainsTextInsideFollowingNormalBlock` covers a normal block whose internal text line boxes must avoid an active right float.
- `UIHTMLFloat.rightFloatConstrainsNestedBlockFormattingContext` covers inherited right-float exclusions flowing through a normal block into a nested BFC child.
- RichText can now receive external float exclusions from a parent block formatting context. Imported exclusions constrain line boxes and BFC placement but do not contribute to the receiver's own content height.
- `BlockLayouter` now forwards inherited float exclusions through normal non-floating blocks and preserves the parent-computed used width for BFC match-parent children next to floats.
- The fixture now keeps the main `.entry` selftext box to the left of `.side` (`side.x=719`, `entry.x=44`, `entry.width=670` in the current smoke run).
- External float exclusions are filtered for fixed-width descendants whose content box is entirely outside the float's horizontal range. This keeps the comment form textarea from being pushed below the sidebar while still letting match-parent content compute a float-constrained used width.
- The smoke test no longer loads `breeze.css`; HTML defaults now come from automatic HTML base-default injection.
- Legacy `<strike>` is registered as an HTML phrasing element and receives default `line-through` styling, removing a missing-element warning from the fixture.
- Horizontal `auto` margins are recomputed at RichText/block layout read points, and the old Reddit vote arrow is centered inside `.midcol` (`arrow.x=17`, `midcol.x=15`, `arrow.width=15`, `midcol.width=19` in the current smoke run).
- Vote arrow sprite CSS now resolves relative to the stylesheet file, preserves negative `background-position`, and is asserted in both a reduced sprite test and the old Reddit smoke test.
- CSS `white-space` now maps browser values such as `normal`, `nowrap`, `pre`, `pre-wrap`, `pre-line`, and `break-spaces` into RichText whitespace-collapse and soft-wrap behavior. `nowrap` suppresses soft wrapping for text and atomic inline boxes, including old Reddit-style inline flat lists, and overflowing text runs no longer force following inline content onto a new line.
- Collapsed whitespace-only text nodes between non-inline boxes no longer create a spurious line box in RichText layout.
- HTML `<button>` is created as an HTML rich-text element with browser-like inline-block defaults so it can participate in CSS display/float layout while still rendering child text. This fixes the top-row placement of old Reddit's `#redesign-beta-optin-btn`.
- RichText virtual block breaks no longer split a line that contains only floats, allowing a following BFC to remain beside those floats in reduced cases.
- Collapsed whitespace-only text between floated inline-display boxes no longer creates in-flow line content. This fixes the old Reddit `#sr-header-area .sr-list` placement: it now sits in the top row beside the floated redesign button and subreddit dropdown, instead of landing at `y=18` and overlapping `#header-bottom-left`.
- The old Reddit subreddit bar now remains a single clipped 18px row under `white-space: nowrap`; the current smoke run reports `srList=(252,0 772x18)`.
- CSS absolute font-size keywords now use the browser scale (`medium` = 16px, `small` = 13px, `x-small` = 10px) instead of the app theme's 12px UI default. This fixes old Reddit title and metadata sizing rules such as `.link .title { font-size: medium }` and `.tagline { font-size: x-small }`.
- Floated inline text elements now follow CSS blockification: once `float` is not `none`, inline spans/anchors stop being rebuilt as inline text and enter the atomic float path. This fixes old Reddit's comment form footer links (`.help-toggle` and `a.reddiquette`) so they align to the right under the textarea.
- `vertical-align` on an inline-block `UITextSpan` now applies to the inline-block box in the parent line, not to its own internal self text. This keeps old Reddit's `.domain a { display:inline-block; vertical-align:middle }` from inflating the anchor's own line box while preserving parent-line alignment semantics.
- `clear` now ignores external float exclusions inherited from an outer formatting context. This matches the old Reddit `.entry { overflow:hidden }` BFC case: its child `.expando { clear:left }` must not clear the sibling `.midcol` float outside `.entry`. The current smoke run moves `.expando` from `y=126` to `y=119.609` and `.usertext-body .md` from `y=131` to `y=124.609`.
- `updateOutOfFlowPosition()` no longer uses the generic `mPacking` early return. That guard kept old Reddit's `#header-bottom-right { position:absolute; right:0; bottom:0 }` at an early `x=297,y=-1` measurement. The current smoke run places it at `x=717,y=41` in the `1024x64` header and asserts its right/bottom edges.
- `input checked` / `checked="checked"` now initializes `UIHTMLInput` checkbox and radio children. Checkbox/radio `value` is preserved as the submitted form value instead of being interpreted as checked state by the child control. This fixes old Reddit's checked sidebar flair checkbox and is covered by `UIHTMLInput.sizeAttribute`.
- Current smoke geometry on 2026-05-31: `side=(719,64 300x1186.36)`, `entry=(47,71 667x531.609)`, `selftextMd=(47,124.609 667x450)`, `commentArea=(5,610.609 1014x391)`, `commentTextarea=(16,694.609 500x100)`, `srList=(252,0 772x18)`, `headerBottomRight=(717,41 307x22)`.
- Remaining visible blockers: the main selftext and comment form still start lower than the Chrome reference; footer/comments vertical spacing still diverges from Chrome; sidebar/footer content below the first viewport still needs tightening; additional header/logo/submit-button sprite details remain.
## Reference Layout Invariants
At 1024px wide, the Chrome reference has these visible structural invariants:
- Header bands consume the top area only; the body content starts below the blue subreddit header.
- `.side` is a 300px right float, positioned at the right edge with small horizontal margins.
- `.content` is a normal block, not floated. It starts at the left margin and is not pushed below `.side`.
- Main post:
- `.rank` and `.midcol` sit to the left of `.entry`.
- `.midcol` is a left float with a narrow fixed width.
- `.arrow.up`, score text, and `.arrow.down` are stacked vertically inside `.midcol`.
- `.entry` has `overflow: hidden`, establishing a block formatting context that sits beside the left floats instead of overlapping them.
- The post body `.usertext-body .md` has a bordered light-blue box with line-wrapped paragraphs.
- `.entry .buttons li` render as a compact horizontal flat list.
- Comment controls and comment bodies line up under the main post content, with nested comment vote columns offset but not collapsed into text.
- Sprite-backed elements such as arrows, the Reddit logo, and submit button nubs render from `sprite-reddit.13AvZYXRW_4.png` using background position.
## Pending Issues To Fix
### 1. Float Placement And Block Formatting Contexts
Old Reddit depends heavily on CSS2 float rules:
- `.side { float: right; width: 300px; }`
- `.content { margin: 7px 5px 0 5px; }`
- `.midcol { float: left; overflow: hidden; }`
- `.entry { overflow: hidden; margin-left: 3px; }`
Important behavior:
- A normal block following a float keeps its normal-flow block position. The float affects line boxes inside that block, not the block border box itself.
- A block formatting context next to a float must not overlap the float. If there is enough horizontal space, it should sit beside the float; otherwise it moves below.
- `overflow` values other than `visible` create a block formatting context for block boxes.
- Floats participate in the current formatting context and must be visible to later sibling line layout until cleared or until their bottom is passed.
- A `clear` inside a nested block formatting context clears floats from that same formatting context, not external float exclusions inherited only for line avoidance and BFC placement.
Implementation gaps to investigate:
- `RichText::layoutWithFloats()` currently owns the float exclusion logic for inline atomic boxes, but full block layout still lacks a first-class float context shared across nested block layouters.
- `BlockLayouter` needs explicit CSS block formatting context semantics instead of relying on generic layout sizing and RichText atom placement.
- Nested line boxes inside `.content` and `.entry` need access to active sibling floats when the CSS formatting context requires it.
Reduced tests to add before the full-page comparison:
- Right float plus following normal block: block x/y unchanged, inner text lines avoid the float.
- Right float plus following `overflow:hidden` block: BFC block is placed beside the float and width is reduced to available space.
- Left float plus following `overflow:hidden` block: old Reddit `.midcol` and `.entry` pattern, with `.entry.x >= midcol.right + margin`.
- Left and right floats on the same line: following inline content uses the remaining middle strip.
- Clear behavior inside a mixed float context: `.clearleft` lands below left floats but not necessarily right floats.
- Clear behavior inside a nested BFC: a child with `clear:left` does not jump below an external sibling float from the parent formatting context.
### 2. Float Width Resolution
Recent work fixed floated `li` elements by making auto-width floated match-parent widgets shrink-to-fit. This needs broader coverage:
- Floats with `width:auto` should use CSS shrink-to-fit width.
- Floats with explicit width should honor width plus margins.
- Percentage widths on floats should resolve against the containing block.
- Min/max width should constrain the shrink-to-fit result.
Old Reddit examples:
- `.side` is fixed 300px and should stay fixed.
- `.midcol` gets a width from an inline style rule in the page, using `ex`: `width: 3.1ex`.
- `.rank` gets `width: 1.1ex`.
Reduced tests:
- Floated block `width:auto` shrink-to-fit.
- Floated block `width:300px` fixed.
- Floated block `width:3.1ex` resolves with font metrics.
- Float margins are included in placement and exclusion rectangles.
### 3. `overflow` Semantics
Old Reddit uses `overflow:hidden` for layout, not only clipping:
- `.entry { overflow: hidden; }`
- `.midcol { overflow: hidden; }`
- `.morelink a { overflow: hidden; text-overflow: ellipsis; }`
Required behavior:
- Block boxes with `overflow` other than `visible` establish a BFC.
- BFC boxes should avoid overlapping active floats in the same block formatting context.
- Actual clipping should be handled separately from BFC creation.
Reduced tests:
- `overflow:hidden` block beside a float.
- `overflow:visible` block can overlap float border box while its line boxes avoid the float.
- `overflow:hidden` clips children visually without changing normal-flow y unexpectedly.
### 4. CSS Display Defaults And Element Creation
The old `breeze.css` dependency has been removed from the old Reddit smoke test. HTML base defaults are now injected automatically when HTML content is attached to a scene.
Old Reddit relies on browser defaults for:
- `html`, `body`, `div`, `p`, `form`, `ul`, `li`, `h1`, `hr`
- inline anchors and spans
- replaced/form controls such as `input`, `textarea`
- hidden inputs and `display:none` nodes
Remaining plan:
- Audit `UIWidgetCreator` and HTML element constructors for browser-like default display.
- Keep theme-specific visual styling in CSS, but move semantic defaults into element creation.
- Add tests that load minimal HTML without `breeze.css` and verify display, margins, and intrinsic sizes for the core elements old Reddit uses.
- Keep input state semantics in `UIHTMLInput`, not in the child theme widgets. Boolean HTML attributes such as `checked` need HTML truthiness (`checked="checked"` is true), while `value` remains the submitted value.
Reduced tests:
- `p` has block display and browser-like top/bottom margins.
- `ul`/`li` default list layout remains correct.
- `form` is block but does not add unexpected margins.
- `input type=hidden` is not visible and does not affect layout.
- `textarea rows/cols` has intrinsic size before external CSS.
### 5. CSS Selector And Stylesheet Coverage
The old Reddit CSS is minified and selector-heavy. The fixture exercises:
- chained classes: `.thing.id-t3_...odd.link.self`
- descendant selectors: `.entry .buttons li`
- child selectors in inline page CSS: `body > .content .link .midcol`
- pseudo-classes/pseudo-elements in many rules
- media queries and vendor-prefixed declarations
Priority:
- Confirm selectors that affect the visible reference image are parsed and matched.
- It is acceptable to ignore unsupported dynamic pseudo-classes initially if the default state renders correctly.
- Pseudo-elements are not critical for the first old Reddit pass except where old Reddit uses `:before`/`:after` for visual icons.
Reduced tests:
- `body > .content .link .midcol` applies to the fixture structure.
- Multiple class selectors match correctly.
- Unsupported pseudo-class rules do not invalidate the whole selector list if another selector is valid.
### 6. Sprite Background Rendering
The reference depends on a sprite image for:
- Reddit logo/header icon.
- Vote arrows.
- Submit button nubs and gradients.
- Mail/preference/user icons.
Required behavior:
- `background-image: url(sprite-reddit.13AvZYXRW_4.png)` resolves relative to the CSS file or document URI as browsers do.
- `background-position: -42px -1678px` draws the correct sprite sub-rectangle.
- `background-repeat: no-repeat` is honored.
- Backgrounds are clipped to the padding/border box correctly.
- CSS custom properties that expand to image values should keep working where used by the bundled or older copied CSS.
Reduced tests:
- Background sprite with negative `background-position`.
- CSS-file-relative URL resolution.
- `background-repeat: repeat-x` button strip rendering.
- Element with explicit width/height and sprite background draws nonblank pixels.
### 7. Auto Margins And Centering
Vote arrows use:
- `.arrow { display: block; width: 15px; margin-left: auto; margin-right: auto; }`
Required behavior:
- Block-level auto horizontal margins center fixed-width children inside their containing block.
- Auto margin recomputation must happen after parent width and child width are known.
- This must work inside floated containers and BFC blocks.
Reduced tests:
- Fixed-width block centered with `margin-left:auto; margin-right:auto`.
- Same case inside a float.
- Same case after parent width changes.
### 8. Text Layout, Paragraph Metrics, And Form Controls
The screenshot is text-heavy. Issues here may look like float bugs even when positioning is correct.
Needs:
- Old Reddit font sizing: `font-size: x-small`, percentages, and inherited line-height.
- Inline-block `vertical-align` must align the atomic inline-block box in the parent line without changing the inline-block's own internal text line metrics.
- Paragraph margin collapse or equivalent spacing close enough for old Reddit.
- `textarea` dimensions from CSS and attributes: comment box should be large and aligned.
- `input type=text` shortlink field width/height and border.
- Link color, visited link color, bold text, and small metadata text.
Reduced tests:
- Absolute `font-size` keywords use the browser scale, while `smaller`/`larger` remain relative to the parent.
- Inline-block with `vertical-align: middle` has the same own text line height as the baseline-aligned inline-block, while the parent line still applies the middle alignment.
- `font-size:x-small` and percentage font-size inheritance.
- Paragraph margins inside `.md`.
- `textarea` with CSS width/height and `rows/cols`.
- `input readonly type=text` fixed width.
- `input type=checkbox checked value=...` initializes checked state and submits the explicit value.
- `input type=radio checked` initializes the radio active state.
### 9. Page Width, Scroll View, And Viewport Semantics
The fixture includes `<meta name="viewport" content="width=1024">`. The `UIWebView` path should behave like a 1024px viewport for this test.
Needs:
- `UIWebView` document container width should match the viewport and propagate into `html`/`body`.
- Body min-height should not force incorrect content placement.
- The full page should scroll vertically without clipping or changing layout widths.
- Screenshot tests should be explicit about viewport size.
Reduced tests:
- `UIWebView` file load sets document width equal to viewport width.
- Body/html min-height follows viewport height but content can exceed it.
- Right-floated sidebar remains at the same x before and after a scroll update.
### 10. Layout Invalidation And Performance
The full old Reddit fixture is expensive in debug/ASAN. Release builds are expected to be much faster, but the fixture still appears to trigger excessive invalidation and repeated layout recomputation. Treat this as a separate investigation track from visual correctness so we do not hide spec bugs behind caching.
Likely sources:
- `UIWebView::loadDocumentData()` loads a large DOM, external CSS, inline CSS, and then adjusts `html`/`body` min-height, causing follow-up invalidations.
- CSS application may update many widget properties one by one, each producing layout invalidation instead of batching style changes per element.
- `UIRichText` rebuilds RichText content from children and can be invalidated repeatedly while descendants are still loading/applying styles.
- Auto-size, match-parent, min/max intrinsic measurement, and float-aware layout can recursively query child sizes and trigger re-entry.
- Image/background/style resource resolution can mutate widget state after the first layout pass.
Plan:
- Add counters around dirty layout enqueue, `UIWidget::updateLayout()`, `UILayouter::layout()`, `UIRichText::rebuildRichText()`, RichText layout, and CSS property application.
- Add an opt-in diagnostic mode for the old Reddit smoke test that prints per-frame and total counts.
- Measure debug without ASAN, debug with ASAN, and release to separate sanitizer overhead from algorithmic churn.
- Batch style application where possible: apply all matched CSS properties, then invalidate layout once per widget.
- Avoid relayout when a property setter receives the same computed value.
- Ensure `UIWebView` viewport/html/body size synchronization happens once after document load when possible, not as a cascade of independent size changes.
- Cache intrinsic width/height results during a layout pass and invalidate them only on relevant style/content changes.
Reduced tests:
- Loading a generated page with many styled block children should produce a bounded number of layout passes.
- Reapplying the same style sheet should not dirty unchanged widget layout repeatedly.
- Measuring min/max intrinsic widths during a single parent layout should not rebuild the same child RichText multiple times.
## Suggested Phase Order
### Phase 1: Measurement Harness
Exit criteria:
- Done. `UIHTML.redditOldThreadWebViewSmoke` passes when `EEPP_REDDIT_OLD_THREAD_VISUAL=1` is set.
- Done. A current eepp screenshot is produced at `bin/unit_tests/output/eepp-reddit-old-thread-current.webp`.
- Done. The smoke dumps important node rects for `.side`, `.content`, `.midcol`, `.entry`, `.arrow`, `.usertext-body`, `.commentarea`, topbar/header nodes, and comment form controls.
Validation:
```sh
projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTML.redditOldThreadWebViewSmoke"
```
### Phase 2: Float/BFC Correctness
Implement a shared CSS float context that block and inline layout can both see. Keep `RichText` responsible for line placement, but do not make it the only owner of float state when block children participate in the same formatting context.
Exit criteria:
- Mostly done for the old Reddit fixture. Reduced float/BFC tests cover the active `.side`, `.content`, `.midcol`, `.entry`, nested BFC, clear, and right-float textarea cases.
- `.side`, `.content`, `.midcol`, and `.entry` now match the reference broad geometry closely enough for later visual details to be meaningful.
- Keep this phase open only for newly found generic float regressions.
Validation:
```sh
projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLFloat.*"
projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTML.redditOldThreadWebViewSmoke"
```
### Phase 3: CSS Visual Features
Prioritize sprite backgrounds, URL resolution, background-position, repeat modes, auto margins, and header/topbar CSS behaviors. These are the biggest remaining visual gap after layout boxes are placed correctly.
Exit criteria:
- Vote arrows draw from the sprite and are centered in `.midcol`. (Reduced and old Reddit smoke coverage exists.)
- Header/logo sprite regions render.
- Submit button strips and nubs render close to reference.
- The `#sr-header-area .sr-list` BFC sits in one clipped topbar row beside the floated redesign button and subreddit dropdown, instead of overlapping `#header-bottom-left` or wrapping through later header rows.
### Phase 4: Defaults And Form Controls
Keep semantic HTML defaults independent from the app theme. `breeze.css` should remain a visual UI theme, not a behavior crutch for HTML content.
Exit criteria:
- Minimal HTML default-display tests pass without loading `breeze.css` for the covered elements.
- The old Reddit smoke test passes without loading `breeze.css`.
- Form controls in the comment box and sidebar match expected broad geometry; checkbox/radio checked state is now wired. Remaining work here is visual polish such as native checkbox/radio appearance, textarea resize affordance, and exact input/button paint.
### Phase 5: Full-Page Visual Gate
Once the page is close enough that screenshot diffs are meaningful, convert the smoke baseline into a real visual comparison against a curated expected image.
Exit criteria:
- Store an eepp expected output image only after review.
- Keep a separate Chrome reference image for human comparison.
- Pixel tolerance should be explicit and justified, because font rasterization and renderer backends may differ.
### Phase 6: Performance Gate
After the main geometry is correct, optimize invalidation and recomputation using the old Reddit fixture as a stress test.
Exit criteria:
- The opt-in old Reddit render reports stable, bounded layout/rebuild counts.
- Debug/ASAN remains tolerable enough for targeted investigation.
- Release render time is documented before and after optimization.
- Any caching preserves all reduced correctness tests.
## Standard Validation Gate
Before considering a phase complete:
```sh
make -C make/linux -j$(nproc)
projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLFloat.*"
projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIHTML.redditOldThreadWebViewSmoke"
git diff --check
```
Run the full suite before landing broad layout changes.
@@ -1,61 +0,0 @@
# Resource ownership follow-up plan
Status: proposed follow-up after Stage 7 completion, 2026-07-24.
The shared-resource ownership migration is complete. This plan is intentionally limited to
validation, documentation, diagnostics, and compatibility-era naming cleanup. It must not reopen
the established `ResourceScope` / `ResourceCatalog` ownership model without a concrete defect.
## 1. Scene lifetime coverage
- Add focused tests proving resources published only into a scene scope are released when its
`UISceneNode` and `ResourceScope` are destroyed.
- Cover textures, drawables, fonts, themes, icons, and shader programs where practical.
- Verify explicitly imported catalogs remain alive only through their actual external owners.
- Assert against registry/catalog contents and retained handles, not only destructor side effects.
## 2. Public ownership documentation
- Audit public resource APIs and consistently document whether parameters and return values are:
owning, retaining, borrowing, or observing.
- Document the required lifetime for borrowed raw pointers returned by UI and GPU APIs.
- Keep hot-path raw pointers where ownership is established elsewhere; do not imply ownership by
converting those APIs to shared handles.
- Add short ownership examples to `ResourceScope`, `ResourceCatalog`, and family-specific services.
## 3. Compatibility-era naming cleanup
- Rename non-owning `ShaderProgramManager`, `VertexBufferManager`, and `FrameBufferManager`
concepts to `ShaderProgramRegistry`, `VertexBufferRegistry`, and `FrameBufferRegistry` throughout
filenames, includes, build files, and documentation.
- Review other `*Manager` names only when their current role is genuinely a registry or service.
- Keep this as an isolated public API cleanup so downstream include breakage is easy to review.
## 4. GPU borrowed-lifetime diagnostics
- Add debug-only validation that borrowed frame buffers, vertex buffers, shaders, and programs are
not used after their owning OpenGL context or renderer has been destroyed.
- Prefer cheap generation/context identity checks at API boundaries over reference counting in hot
rendering paths.
- Do not add OpenGL context-loss recreation support; current supported platforms do not require it.
## 5. Static initialization audit
- Build a Clang diagnostic configuration with `-Wglobal-constructors` and
`-Wexit-time-destructors` to identify C++ work performed before `main()` and after its return.
- Produce a linker-level inventory of `.init_array` entries for representative executables to find
constructors hidden in libraries or translation units excluded from Clang diagnostics.
- Prioritize globals that allocate memory, register callbacks/resources, depend on singleton order,
or retain graphics objects. Constant-initialized POD data is not a migration target.
- Move executable state into `main()` scopes and callback lambdas. Replace necessary library
globals with function-local statics only when process lifetime is intentional and documented.
- Track the baseline count and prevent new non-trivial global constructors in CI once existing
cases have been classified.
## Validation
- Run the complete unit-test suite and all normal platform CI jobs after each focused change.
- Keep `git diff --check` clean and run examples affected by shutdown-order changes under ASan.
- Confirm GPU/resource handles are destroyed before `Engine::destroySingleton()` in examples and
tools that own them locally.
@@ -1,901 +0,0 @@
# eepp shared-resource ownership architecture
Status: implementation complete through Stage 7, 2026-07-24.
This document freezes the contracts that must be true before the public texture API is changed. The
implementation may refine names and small mechanics, but changing an invariant below requires an
explicit architecture revision.
## 1. Objective
Replace raw manager ownership, global load side effects, manual deletion, and `ownIt` flags with
explicit shared ownership throughout eepp and all in-repository consumers.
The final model is:
- Consumers, immutable source objects, catalogs, and caches own resources with strong handles.
- A live registry observes resources weakly for diagnostics, accounting, and leak
reporting. It is never searched for semantic names.
- Catalogs define names and persistence.
- Scopes define which catalogs and typed caches are visible.
- GPU resources remain graphics-thread-affine. A final owning release may happen on a worker, but
the texture deleter only performs a thread-safe handoff to TextureFactory. Actual destruction runs
through the graphics/display lifecycle.
- UI drawable resolution is layered over Graphics resource lookup; browser caching and navigation
remain outside Graphics.
- A UISceneNode can own a scope and resolver, but neither texture lifetime nor pure Graphics usage
requires a UISceneNode.
This is an intentional repository-wide API break. There will be no compatibility API, no `Shared`
suffixes, and no retained `ownIt` overloads.
## 2. Confirmed hazards in the current code
These are not hypothetical migration risks:
- `TextureAtlasLoader` queues `TextureFactory::loadFromFile()` and `loadFromPack()` calls, discards
their results, then later resolves the textures globally by name. Immediately switching the
factory to weak, unpinned retention would compile and destroy each loaded texture at the end of
the lambda.
- `TextureLoader` stores and returns `Texture*`; its callback and unload behavior depend on the
factory owning the object.
- `TextureRegion` stores `Texture*`, and constructors taking a texture ID resolve that raw pointer
through the factory.
- `FrameBuffer` stores `Texture*` and deletes it directly in its destructor.
- `TextureAtlas`, glyph drawables, SVG icon raster caches, GIF loading, and font caches retain raw
texture pointers.
- `Texture::~Texture()` performs GL deletion and reaches `Engine::instance()` and
`TextureFactory::instance()`. Shared ownership must replace this singleton-dependent destruction
with TextureFactory-controlled deferred release on the graphics thread.
- `TextureRegion::isStateful()` and `DrawableGroup::isStateful()` return false despite draw/update
methods mutating destination size, position, or child state.
- `UIImage`, `StateListDrawable`, and `DrawableGroup` temporarily mutate drawable color, alpha,
size, position, or children while drawing.
- `Models::Variant` copies both an owning flag and the same raw drawable pointer, allowing two
copies to believe they exclusively own one allocation.
- `EE_MEMORY_MANAGER` requires allocation registration through `eeNew` and removal through
`eeDelete`; default `shared_ptr` deletion of an `eeNew` allocation would leave tracking invalid.
- `Engine::~Engine()` destroys `Renderer` before `ShaderProgramManager`, while
`ShaderProgram::~ShaderProgram()` calls `GLi->deleteProgram()` directly.
- The global HTTP pool is cleared after Graphics resources and managers, allowing asynchronous
producers to outlive systems they can mutate.
## 3. Frozen ownership vocabulary
### 3.1 Public handle representation
eepp will expose `std::shared_ptr` and `std::weak_ptr` through consistent aliases:
```cpp
template <typename T> using ResourcePtr = std::shared_ptr<T>;
template <typename T> using ResourceWeakPtr = std::weak_ptr<T>;
using TexturePtr = ResourcePtr<Texture>;
using TextureWeakPtr = ResourceWeakPtr<Texture>;
```
This is an explicit API/ABI choice. eepp accepts that users can use standard shared-pointer
operations. The library nevertheless exposes no API for adopting an arbitrary resource raw pointer,
and resource constructors remain protected/private where practical.
Requirements:
- A resource allocation has exactly one control block.
- Owning APIs return handles. They do not return a raw pointer plus an ownership convention.
- Long-lived borrowed raw pointers are forbidden unless the field or API documents the owner that
dominates the borrow. Local `.get()` views inside a call are allowed.
- Shared-library and static-library builds must test the chosen handle/deleter behavior.
### 3.2 Centralized creation and deletion
All ref-counted eepp resource allocations go through one internal creation path with an
eepp-compatible deleter:
```cpp
template <typename T> struct ResourceDeleter {
void operator()( T* resource ) const noexcept { eeDelete( resource ); }
};
template <typename T, typename... Args>
ResourcePtr<T> makeResource( Args&&... args );
```
Factories use an equivalent private helper for protected constructors. `std::make_shared` is not
used for tracked eepp resources unless the memory manager is redesigned to understand its combined
allocation. No second control block may be created from `handle.get()`.
Texture is the deliberate exception to immediate `eeDelete`: its factory-controlled deleter may
queue the final raw object from any thread. `TextureFactory::collectReleasedTextures()` performs the
eventual `eeDelete` on the graphics thread after queued rendering has been flushed. This is the same
deferred destruction contract used by scene nodes; it is not a general GPU disposal system.
### 3.3 Identity, keys, and labels
These concepts are distinct:
- `ResourceId` is immutable and process-unique across Engine recreation in tests. For textures it
is the value returned by `Texture::getTextureId()`; there is no separate factory-internal texture
ID.
- `ResourceKey` is the immutable canonical semantic lookup key. Equality compares the complete key,
never only a hash.
- `displayName` is diagnostic text and may change without changing identity or catalog indexes.
- Aliases are catalog entries, not mutable fields used as registry indexes.
A process-wide monotonic ID source must not reset when an Engine singleton is recreated by tests.
## 4. Graphics ownership layers
```text
Engine
├── Renderer and contexts
├── TextureFactory
│ ├── weak live-texture registry
│ └── deferred released-texture queue
├── GlobalResourceCatalog (intentional strong persistence)
└── Default ResourceScope (local catalog + explicit imports)
Application/scene ResourceScope
├── local ResourceCatalog
├── explicitly imported catalogs
└── typed caches
UI::DrawableResolver
├── CSS/image/icon/glyph parsing
├── node and UISceneNode context
└── delegates texture/source lookup to Graphics::ResourceScope
UI/Network::WebResourceCache
├── request/cache partitioning
├── in-flight request coalescing
├── TTL/LRU/byte-budget retention
└── per-document leases and subscribers
```
Engine coordinates the Graphics lifetime roots directly. TextureFactory coordinates texture
creation, weak observation, and deferred destruction, but does not provide semantic lookup or
normal strong retention. No Graphics class depends on UI.
### 4.1 LiveResourceRegistry
The registry observes every instantiated texture weakly:
```cpp
struct TextureRecord {
ResourceId id;
ResourceKey creationKey;
std::string displayName;
TextureWeakPtr texture;
std::size_t memoryBytes;
ResourceFlags flags;
};
```
It has no strong resource field, no `ownerScope`, no semantic name resolution, and no public
`unregister()` operation.
Primary operations are snapshots, expiration purging, and live iteration. Diagnostic snapshots
return metadata plus weak handles, not a vector of owning texture handles:
```cpp
TextureRegistrySnapshot snapshotTextures() const;
void purgeExpired();
```
Opening `UITextureViewer` must not retain all textures. The viewer may lock a weak handle for one
render operation and may strongly retain only a user-selected texture.
Destruction, callbacks, and GL operations never occur while a registry lock is held.
Snapshot metadata, including texture memory usage, is copied from the texture while the registry
temporarily locks its weak handle. Texture keeps its existing memory size as the single source of
truth; accounting does not require separate shared state or callbacks into the factory.
### 4.2 ResourceCatalog
A catalog maps complete canonical keys/aliases to strong handles. It provides semantic lookup and
intentional persistence:
```cpp
class ResourceCatalog {
public:
void publish( ResourceKey key, TexturePtr texture );
TexturePtr findTexture( const ResourceKey& key ) const;
bool erase( const ResourceKey& key );
void clear();
};
```
Publishing is the final global pin model. The low-level TextureFactory has no `ResourcePin` argument
and no factory-owned strong pin. Independent catalogs/caches naturally provide independent pins.
If temporary explicit pinning is needed, `ResourceCatalog::pin()` returns an independent RAII token;
there is no shared boolean or one global `strong` field.
A higher-level scope convenience may accept a retention option and publish into that scope's catalog,
but texture creation and decoding remain unpinned operations.
### 4.3 ResourceScope
`Graphics::ResourceScope` performs Graphics-only lookup and loading:
```cpp
class ResourceScope {
public:
TexturePtr findTexture( const ResourceKey& key ) const;
TexturePtr loadTexture( const TextureRequest& request );
void publishLocal( ResourceKey key, TexturePtr texture );
void importCatalog( ResourceCatalogPtr catalog );
};
```
Frozen lookup rules:
- Search the local catalog, then explicitly imported catalogs in deterministic order.
- Never search the live registry.
- Never implicitly search a parent, host scene, sibling scene, or every live resource.
- The default Graphics scope imports the global catalog explicitly.
- A UI/application scene imports the default resource catalog automatically for the common case;
callers can disable this at construction for strict isolation and then import only the catalogs
they deliberately expose.
- A Web document receives the same default-catalog baseline unless created with automatic import
disabled. It never implicitly inherits host, sibling, or other document-local catalogs.
- Scopes import catalogs, not arbitrary scopes. This avoids recursive lookup and import cycles.
Pure `EE::Graphics` users may use TextureFactory for unpinned creation or Engine's default Graphics
scope/catalog for named persistent resources. No UISceneNode is involved.
### 4.4 TextureFactory final API role
TextureFactory creates, decodes, uploads, and updates textures. Creation names return `TexturePtr`
directly and do not retain it:
```cpp
TexturePtr createEmptyTexture( ... );
TexturePtr loadFromPixels( ... );
TexturePtr loadFromPack( ... );
TexturePtr loadFromMemory( ... );
TexturePtr loadFromStream( ... );
TexturePtr loadFromFile( ... );
```
The factory registers every result with its weak live registry. It has no semantic `getByName()` or
`getByHash()` API, no public deletion/removal API, no public registry detachment, and no owning
`getTextures()` API. Named lookup belongs to a catalog/scope; diagnostics use registry snapshots.
The `TexturePtr` control block uses a factory-controlled deleter. Final release appends the raw
texture to TextureFactory's released queue; it does not execute `Texture::~Texture()` immediately.
`Window::display()` flushes pending batches and then calls
`TextureFactory::collectReleasedTextures()` while the active context is current. Engine shutdown
performs the same collection explicitly because no later display is guaranteed.
## 5. GPU lifetime and threading
### 5.1 Graphics-thread lifetime contract
GPU operations remain graphics-context-affine and follow the existing graphics/update or explicitly
shared-context rules. Releasing the final `TexturePtr` is different: it performs no GPU operation and
may enqueue the raw texture from any thread. Only collection and actual destruction require the
graphics thread and a current context.
Debug builds assert the collection boundary. The design does not add a generic device state, epoch,
or disposal mechanism for other GPU resource families; TextureFactory's small deferred-release queue
is the texture-specific lifetime boundary already required by batched rendering.
### 5.2 Texture deferred destruction
Final `TexturePtr` release queues the Texture object in TextureFactory. It remains allocated until a
safe collection point:
```cpp
void Window::display( bool clear ) {
GlobalBatchRenderer::instance()->draw();
TextureFactory::instance()->collectReleasedTextures();
swapBuffers();
// ...
}
```
Batch flushing precedes collection because the current renderer still keeps borrowed texture state.
Long-lived render queues must eventually retain TexturePtr themselves. The released queue is also
drained during Engine shutdown after consumers are released and before TextureFactory, Renderer or
contexts are destroyed.
Other self-contained GPU classes retain their direct, graphics-thread destruction model. They are
not routed through TextureFactory and do not acquire generic lifetime machinery unless a later
ownership migration demonstrates a concrete need.
### 5.3 Resource mutation and loading threads
- Decode and network work may run off-thread.
- GPU create/upload/reload/mutation and final ownership release obey the graphics-thread/shared-
context contract.
- Registries and caches are thread-safe at their boundaries.
- UI mutation executes on the scene/main thread and remains protected by scene generation tokens.
- A generation token controls whether a subscriber may mutate a scene; it does not own a resource or
a shared request.
- No callback, resource destruction, or device command executes while a registry/cache mutex is held.
## 6. Engine shutdown and restart contract
Engine teardown must be reordered around producer shutdown, consumer release and valid GL contexts:
1. Mark Engine and Web cache/resource delivery services as shutting down; reject new work.
2. Invalidate scene/document async subscribers and stop accepting main-thread resource deliveries.
3. Cancel/stop and join resource/network/decode producers that can create resources or callbacks.
4. Destroy scenes, documents, UI resolvers, document leases, scene scopes, and application caches.
5. Clear application/global catalogs and remaining manager-owned handles.
6. Flush/discard pending rendering submissions, then collect TextureFactory's released textures.
7. Inspect the weak texture registry. Debug/tests assert that no unexpected strong TexturePtr
remains; a defensive shutdown sweep may release the GPU payload of reported survivors.
8. Destroy device-dependent managers in audited order while Renderer and contexts remain valid.
9. Destroy TextureFactory, Renderer, windows/contexts and backend state in that order.
The exact manager list will be produced by the Stage 1 GPU audit, but these order constraints are
fixed:
- HTTP/decode producers stop before resource consumers and GPU systems are dismantled.
- `ShaderProgramManager` releases programs before Renderer/GL dispatch is destroyed.
- TextureFactory's deferred released queue is empty before its context disappears.
- A TexturePtr surviving Engine destruction is a project-contract violation, reported by debug
builds and tests rather than supported through a second device-lifetime architecture.
- Destructors cannot recreate singletons.
- Tests release all resource handles before recreating Engine state from zero.
## 7. Drawable model
Shared lifetime and shareable instance state are separate concerns. `isStateful()` is not a sharing
contract and will not be used as one.
### 7.1 Source/instance split
Resource resolution caches immutable source data. UI consumers own per-consumer drawable instances:
```cpp
using DrawablePtr = ResourcePtr<Drawable>;
DrawablePtr Drawable::clone() const;
DrawablePtr DrawableResolver::createDrawable( const DrawableRequest& request );
```
Stage 4 established this contract without introducing a parallel `DrawableSource` class hierarchy.
Existing drawable resource types serve as source prototypes while retained by an atlas, theme,
icon, catalog, or resolver. A prototype is never handed directly to an unrelated consumer:
`clone()` returns independently mutable presentation state while sharing underlying
texture/resource handles. This is simpler than duplicating every drawable type into source and
instance classes and remains compatible with introducing immutable source-only types later when a
concrete resource requires one.
eepp continues to use `Drawable::Type` for runtime drawable dispatch. Generic handle conversion
checks that tag and then uses `static_pointer_cast`; cloning code for a statically known concrete
type also uses `static_pointer_cast`. The ownership migration does not introduce RTTI casts.
Representative split:
- `Texture` is shared GPU/resource data, not a globally shared mutable drawable instance.
- `TextureRegion` prototypes and instances retain a TexturePtr; instances copy rectangle, offset,
intrinsic size, destination size, tint, and position.
- `NinePatch` instances clone their nine mutable region children while sharing the textures.
- `TextureDrawable` holds per-consumer destination size, tint, alpha, and position while retaining
the shared TexturePtr.
- `StateListDrawable`, `DrawableGroup`, and `Sprite` are per-consumer state machines/instances that
refer to source handles or private child instances.
`DrawableImageParser::createDrawable()` always returns a fresh consumer instance for CSS-generated
or resolved content, even when its immutable source came from a cache.
`UIIcon`, `UIGlyphIcon`, and `UISVGIcon` expose the split directly:
```cpp
const DrawablePtr& UIIcon::getSource( int size ) const;
DrawablePtr UIIcon::createDrawable( int size ) const;
```
`getSource()` supports lookup, measurement, and immediate rendering without cloning an existing
prototype. Glyph and SVG icons may materialize and cache a missing size source once.
`createDrawable()` is the explicit consumer-instance boundary for callers that retain the drawable
or need persistent independent state.
Immediate, single-threaded render paths may borrow an icon source and temporarily change
presentation state when they restore every changed value before returning and never retain the raw
pointer. Retained widget, menu, model, animated, or otherwise independently stateful consumers must
create and own an instance. Shared child mutation remains forbidden where drawing can be reentrant
or where the complete state cannot be restored locally.
No rendering callback may call `clone()`, `UIIcon::createDrawable()`, or an API that performs either
operation internally. It must render either a previously retained instance or a borrowed source
under the temporary-state contract above. The Stage 4 call-site audit classifies all remaining
direct `clone()` calls as:
- implementations recursively cloning their private child state;
- constructors and setters adopting a private region/sprite/map instance;
- theme, skin, icon, CSS, and name-resolution source-to-instance boundaries;
- widget deserialization and one-time assignment; or
- focused ownership tests.
The code editor lock icon and ecode debugger, linter, LSP breadcrumb, and autocomplete icon paths
borrow their per-size icon sources at the point of immediate rendering and restore temporary color
changes before returning. Icons assigned to widgets, menus, or models still use owned instances.
### 7.2 Consumer API
Consumers store a strong per-consumer instance:
```cpp
UIImage* UIImage::setDrawable( DrawablePtr drawable );
DrawablePtr UIImage::getDrawable() const;
```
The same rule applies to CSS layers, buttons, skins, icon instances, state lists, and groups. All
`ownIt` flags and manual drawable deletion paths are removed in the same drawable migration stage.
`Models::Variant` will be structurally migrated to `std::variant` (or an equivalently safe
non-union representation) with `DrawablePtr` as a normal non-trivial member. Its old owning flag and
raw drawable alternative are removed.
### 7.3 Resource notifications
`DrawableResource::Unload` is removed as a consumer lifetime mechanism. A strong owner cannot be
notified that its object vanished, and destructor callbacks into a partially destroyed most-derived
object are unsafe.
Mutable/reloadable source data uses a typed change/invalidation signal with RAII connection tokens.
Callbacks capture weak lifetime tokens rather than raw consumer `this` pointers. Registry expiration
is observed through weak handles and snapshots, not an object destructor callback.
## 8. UI resolution layering
`DrawableSearcher` is replaced, but not by putting all of its behavior into Graphics::ResourceScope.
`UI::DrawableResolver` owns UI-specific interpretation:
- CSS gradients and functions
- `url(...)` and scene-relative URI resolution
- icons, glyphs, sprites, and generated drawable instances
- node/scene context and UI source-to-instance creation
It delegates texture/source lookup and creation to the scene's `Graphics::ResourceScope`. A default
UI resolver can use the default Graphics scope for UI applications without custom scenes, but pure
Graphics does not depend on it.
Browser request policy, cookies, navigation, and cache leases are not responsibilities of either
DrawableResolver or Graphics::ResourceScope.
## 9. Web document ownership and shared cache
### 9.1 Topology
Each UIWebView document has a distinct `DocumentSessionId`, document scope, and cache lease. Multiple
documents may use one shared WebResourceCache:
```text
Document scope/session A ─┐
├── shared WebResourceCache partition
Document scope/session B ─┘
```
Navigation changes/releases only that document's lease. It never directly purges entries required
by another document. Widgets retain their currently displayed resource/source through ordinary
strong handles independently of cache retention.
### 9.2 Cache key and isolation
```cpp
struct OriginKey {
std::string scheme;
std::string normalizedHost;
Uint16 effectivePort;
};
struct WebResourceKey {
CachePartitionId partition;
CanonicalURI uri;
ResourceKind kind;
DecodeOptions decode;
RequestVariant requestVariant;
};
```
`CachePartitionId` represents the intentionally shared HTTP/cookie/authentication context. Different
cookie jars or credential contexts do not share entries merely because a URI matches. Request
variants account for content-affecting headers until full HTTP `Vary` support exists.
Canonicalization rules are fixed at the cache boundary:
- Same-origin means normalized scheme, host, and effective port all match.
- URI fragments are removed; query strings remain part of the key.
- Redirect metadata records both request URI and canonical final URI without merging partitions.
- File paths use platform-aware canonicalization with explicit symlink/case behavior.
- Data URIs use a content hash plus decode options and enforce resource-size limits.
- Hashes accelerate lookup but full keys determine equality.
### 9.3 Entry state and request coalescing
```cpp
enum class LoadState { Empty, Loading, Ready, Failed, Cancelled };
struct WebCacheEntry {
WebResourceKey key;
LoadState state;
TexturePtr retainedResource;
TextureWeakPtr liveResource;
MonotonicTime lastUsed;
MonotonicTime expiresAt;
std::size_t retainedBytes;
UnorderedSet<DocumentSessionId> activeLeases;
std::vector<WeakSubscriber> subscribers;
};
```
Concurrent requests for one key share one fetch/decode/upload operation. Each subscriber has its own
document session and scene generation. A stale subscriber is removed without cancelling delivery to
other current subscribers. Cancellation of the shared operation occurs only when policy permits and
no subscriber/cache requirement remains.
Retention uses monotonic TTL, LRU information, and per-partition/global byte budgets. Same-origin
navigation may renew a document lease; cross-origin navigation releases that document's old-origin
lease. External consumer handles remain valid regardless of cache eviction.
Failure entries define retry/backoff and do not become permanent accidental cache hits.
## 10. Migration strategy
No externally released intermediate state is required. Temporary duplicated retention is allowed on
the feature branch to keep behavior valid while the repository-wide API break is assembled.
### Stage 0: contract freeze and inventories
Status: complete. Its accepted conclusions are incorporated into this architecture baseline.
Deliverables:
- This architecture document accepted or amended.
- Complete inventory of GPU resource classes and direct GL deletion sites.
- Complete inventory of stored raw `Texture*`, texture-ID lookup, ignored texture-load returns,
loader callback signatures, and factory deletion calls.
- Complete drawable mutation/shareability inventory.
- Build-matrix decision for shared/static libraries and `EE_MEMORY_MANAGER`.
- Concrete shutdown dependency graph for Engine-owned producers, consumers, managers, Renderer, and
contexts.
Exit criterion: no unresolved ownership, lookup, last-release-thread, Engine restart, scope import,
or drawable-sharing contract blocks substrate implementation.
### Stage 0.5: prerequisite bug fixes
Status: complete, 2026-07-14. Concrete defects discovered by the ownership audit were fixed with
focused regression coverage while preserving current raw factory ownership:
- HTTP Pool clears clients outside its mutex so joined callbacks can re-enter without deadlock.
- Externally executed HTTP tasks cannot retain a dangling raw Http after Pool destruction.
- TextureAtlasLoader joins/stops ResourceLoader work before callback-visible loader state is
destroyed.
- Engine clears TextLayout before destroying the default ResourceScope and its FontService.
- Engine stops asynchronous resource producers before resource consumers and GPU managers.
- UISceneNode's static async delivery queue has an explicit shutdown purge/rejection boundary.
- Obsolete FrameBuffer context-loss reload APIs were removed.
TextureLoader's static callback registry is deliberately removed with Stage 1 live observation.
Drawable ownership defects remain assigned to their structural Stage 4 replacement.
### Stage 1: texture lifetime scaffolding, with old factory retention still active
Status: complete, 2026-07-15. Stable process-wide ResourceId, eepp-compatible resource
aliases/deleter, and TextureFactory's weak live-texture registry are
implemented.
`Texture::getTextureId()` now returns that ResourceId directly, and every identity-based texture
API and stored consumer uses ResourceId; the only other texture identifier is the OpenGL handle.
The factory now retains the single TexturePtr control block internally while public texture APIs
still return raw pointers, preserving its old strong-retention behavior until Stage 2. Texture
memory updates no longer reach the factory singleton, and texture destruction no longer unregisters
itself through a singleton callback. Final handle release now queues Texture destruction in the
factory; `Window::display()` collects only after batch flush, and Engine shutdown performs a final
collection while its context remains valid. Debug assertions enforce graphics-thread release and
collection, while shutdown diagnostics report and defensively release GPU payloads from surviving
external handles. TextureLoader's static callback registry is removed. UITextureViewer reconciles
weak snapshots only when the atomic live-registry generation changes and strongly retains only the
currently enlarged texture.
Implement stable ResourceId, centralized eepp-compatible handle creation, the weak TextureFactory
live registry, TextureFactory's deferred released-texture queue, shutdown diagnostics,
and graphics-thread assertions. Integrate collection into Window::display() after batch flush and
into Engine shutdown before Renderer/context destruction. Reorder Engine teardown using the audited
dependency graph. Do not generalize this substrate to self-contained GPU resource classes.
Remove TextureLoader's static callback registry in the same change and migrate UITextureViewer to
the weak live registry. Do not add an intermediate synchronization/reset contract to the old API.
The old raw factory ownership remains temporarily so this internal stage cannot make resources
disappear. Public texture APIs have not switched yet; the deferred shared-pointer deleter becomes
active in the complete Stage 2 TexturePtr cut.
Exit tests:
- Released textures are deleted only at display/final shutdown collection points.
- Pending batches flush before texture collection.
- Engine teardown leaves no pending released textures or unexpected live registry entries.
- Repeated test-only Engine create/destroy cycles start with empty resource state.
- Worker-thread final release only queues the texture; it does not run GL or destruction work.
- `EE_MEMORY_MANAGER` accurately removes texture allocations through the factory-controlled deleter.
### Stage 2: one complete TexturePtr ownership cut
Status: complete, 2026-07-19. TextureFactory creation and acquisition APIs now return TexturePtr,
and TextureLoader exposes handle-based state with `reset()` replacing destructive `unload()`
semantics. TextureRegion, atlases/loaders, framebuffers, font pages and glyphs, nine-patches,
sprites, particle systems, SVG caches, UI image/background paths, maps, tools, tests, ecode, and
eeiv now retain texture handles. Atlas worker loads store their returned handles directly instead
of depending on later global lookup. BatchRenderer retains handle-aware submissions until flush;
its raw overload is limited to Texture's immediate draw path, whose queued object lifetime is
protected by display-time deferred destruction. Sprite's obsolete texture-owner flag and public
factory texture-removal APIs are removed. Factory-wide strong retention remains only as the
planned temporary bridge to Stage 3.
Change creation/acquisition APIs to return TexturePtr and migrate every required holder in the same
repository-wide cut. During conversion, TextureFactory temporarily retains strong handles so an
unclassified ignored result cannot silently expire.
At minimum migrate:
- TextureLoader state, return types, callbacks, unload behavior, and async captures
- Texture GIF frame ownership
- TextureRegion and region-source texture ownership
- TextureAtlas, TextureAtlasLoader, and texture vectors
- FrameBuffer attachment ownership
- fonts and glyph texture caches
- nine-patches and region chains
- SVG raster caches and UI icons
- sprites and particle systems that retain textures/regions
- UIImage and UINodeDrawable texture paths
- debug texture viewer and live-resource diagnostics
- eepp, ecode, modules, examples, tools, and tests
Every stored raw Texture pointer is classified as strong, weak, or a short borrow dominated by a
documented owner. Add a source audit/clang-tidy check where practical.
Exit criteria:
- Regions, atlases, framebuffers, fonts, glyphs, UI consumers, and async work retain dependencies.
- No ignored factory load result is relied upon for later global lookup.
- No public texture delete/remove API remains.
- Diagnostic snapshots do not pin resources.
- Temporary factory retention can be removed without failing ownership tests.
### Stage 3: catalog and scope ownership cutover
Status: complete, 2026-07-19. Engine now owns the global catalog and default Graphics scope;
UISceneNode owns an isolated scope that can be shared explicitly. TextureFactory is an unpinned
creator and weak live registry with no semantic name/hash lookup. Atlas, map, UI image/background,
DrawableSearcher, ecode, tests, and other name-based consumers publish to and resolve through their
explicit scope. Catalog aliases and imports provide intentional persistence and deterministic
sharing. Worker-thread final TexturePtr release is handed to the factory's thread-safe queue and
actual deletion remains display/shutdown-bound. The full cut also corrected TextureLoader's decoder
pixel allocator provenance, which asynchronous scoped loading exposed.
Implement the global catalog, default Graphics scope, application/scene catalogs, explicit imports,
immutable keys, and aliases. Move intended persistent resources from temporary factory retention into
catalogs/caches. Remove factory-wide strong retention and activate final unpinned creation.
Remove semantic TextureFactory name/hash lookup and migrate every lookup to a scope/catalog.
Exit criteria:
- Dropping the last real consumer/catalog/cache handle expires a texture.
- Duplicate names in unrelated scopes resolve independently.
- Sibling/document resources are invisible without explicit catalog import.
- Global resources persist only because the global catalog owns them.
- Live diagnostic records cannot be resolved semantically.
- Scope destruction does not invalidate externally retained resources.
### Stage 4: drawable source/instance conversion
Status: complete, 2026-07-20. Drawable ownership
now uses `DrawablePtr`; textures create private
`TextureDrawable` wrappers; mutable prototypes implement `clone()`; sprites, state lists,
skins, groups, nine-patches, regions, glyphs, gradients, and primitive drawables clone their
presentation state. UIImage, UINodeDrawable, menus/icons/themes, parsers, editor/tool consumers,
maps, physics, ecode, and eeiv were migrated in the same API cut.
`DrawableResource::Unload` and callback IDs were replaced by Change-only RAII connections.
Callback state is allocated lazily on the first connection, so ordinary drawable resources carry
no callback allocation. Callback storage and notification snapshots use small inline buffers;
snapshotting preserves safe self-disconnection and reentrant mutation during notification without
allocating in the common case.
`Variant` stores DrawablePtr outside its scalar union. UITextureRegion and ScrollParallax render
with local geometry rather than temporarily resizing shared source regions; region-based map
objects retain private instances. `DrawableSearcher` already returns fresh instances as a safe
bridge, but its replacement by the layered UI resolver remains Stage 5.
`UIIcon::getSource()` now returns a cached source/prototype for lookup, measurement, and immediate
single-threaded rendering under the temporary-state restoration contract, while
`UIIcon::createDrawable()` explicitly creates one private consumer instance. `UIGlyphIcon` and
`UISVGIcon` cache their lazily materialized sources under the same contract. The complete `clone()`
call-site audit found no remaining render-loop cloning. The code editor, debugger, linter, LSP
breadcrumb, and autocomplete draw-only paths borrow sources directly; retained widget and menu
icons continue to own instances.
Introduce source types and per-consumer instances, remove shared draw-state mutation, replace manual
ownership with DrawablePtr, remove Unload lifetime callbacks, add RAII change connections, and
migrate Variant's storage.
Convert UIImage, UINodeDrawable layers, StateListDrawable, DrawableGroup, Sprite, UIPushButton,
UISkin, UIIcon, themes, and DrawableImageParser in one coherent cut.
Exit criteria:
- Two consumers using one source have independent tint, alpha, size, position, state, and animation.
- Nested/reentrant drawing leaves no shared source or child modified.
- Drawable groups do not reposition shared child instances.
- Variant copying cannot duplicate exclusive ownership.
- No drawable `ownIt` or manual child deletion remains.
### Stage 5: layered UI resolution
Status: complete, 2026-07-21. `DrawableSearcher` was removed from Graphics. `ResourceScope` now
provides scoped drawable lookup for textures, atlas regions, nine-patches, and sprites, preserving a
pure Graphics entry point with no UI dependency. Each `UISceneNode` owns an allocation-free
`UI::DrawableResolver` that reads the scene's current scope and referer when resolving file, data,
HTTP, and named drawable references. UI images, sprites, menus, CSS parsing, and tests use the scene
resolver; callers without a scene explicitly construct one over `defaultResourceScope()`.
CSS icon resolution now uses the requesting node's scene instead of the process-global scene.
Texture names remain isolated by local/imported catalogs and become visible across scenes only when
their scopes or catalogs are shared intentionally. Atlas, nine-patch, and sprite manager migration
to scoped catalogs remains Stage 7 work for those resource families.
Implement UI::DrawableResolver and replace DrawableSearcher. Scene resolvers delegate Graphics work
to their explicit scope. Keep CSS/icon/glyph interpretation in UI and cookie/navigation concerns in
Web services.
Exit criteria:
- Pure Graphics works without UI.
- UI resolves through its scene/application scope.
- Embedded documents cannot see sibling resources accidentally.
- Host/application assets require explicit export/import.
### Stage 6: WebResourceCache and document leases
Status: complete, 2026-07-21. Each UISceneNode now owns a WebResourceCache document session with
an explicit cache partition and navigation generation. UIWebView advances that session when the
replacement document is installed, after the previous document has been detached. Document,
stylesheet, remote font, image, and CSS background image
requests share canonical fragment-free keys that include the partition, request method/body and
headers, resource kind, and image decode options.
Concurrent requests coalesce into one fetch and one image decode/upload. Subscribers retain their
own document generation, so navigation or destruction removes stale delivery without cancelling a
request needed by another session. Applications can install one cache and explicit partition into
multiple WebViews to share eligible public resources; distinct partitions and content-affecting
headers remain isolated. Scene ResourceScope entries remain an explicit override but fetched Web
resources are retained only by consumers, document leases, and the cache.
Completed entries use monotonic TTL and LRU timestamps plus a configurable byte budget. Active
document leases are not evicted; the TTL starts when the final document lease is released, allowing
Back/Forward navigation to reuse resources regardless of how long the previous document remained
open. Navigation releases only that session's previous leases, and UIWebView performs throttled
cache maintenance so expired unleased entries are collected even when no new requests complete. Failed loads
retry, redirect/final cookies are delivered only to current subscribers, and image upload is
dispatched through the scene's guarded main-thread resource queue. Common lease lists use inline
small vectors, and completed entries release starter request bodies, headers, and dispatchers.
Implement cache partitions, canonical keys/origins, per-document sessions and leases, in-flight
coalescing, per-subscriber generation guards, retries, TTL/LRU, and byte budgets. Integrate WebView
navigation at its existing document replacement boundary.
Exit criteria:
- Shared tabs reuse eligible resources without sharing document ownership.
- Navigation in one tab cannot evict another tab's active lease.
- Different cookie/auth partitions cannot reuse credential-dependent responses.
- Stale subscribers do not block current subscribers.
- Same-origin retention and cross-origin lease release follow policy.
- Cache memory remains within configured budgets.
### Stage 7: remaining resource families
Status: complete, 2026-07-24. Nine-patches and texture atlases are migrated. `NinePatch::New()`,
`TextureRegion::New()`, and `TextureAtlas::New()` return handles. Atlases retain region and texture
handles; loaders retain and publish atlas handles; theme-owned catalogs retain their named atlas
sources; and scene `ResourceScope` imports make those sources visible intentionally.
`NinePatchManager`, `TextureAtlasManager`, and `GlobalTextureAtlas` were removed. Removing a catalog
entry releases only catalog ownership and leaves separately retained consumers valid.
This is the required pattern for the remaining process-wide singleton resource managers. A
singleton must not be modernized into another process-global semantic namespace. Each family moves
to ordinary catalogs owned by its application, scene, theme, document, or other natural lifetime
boundary. `globalResourceCatalog()` is reserved for resources deliberately published process-wide;
scene scopes see non-global resources only through their local catalog or explicit imports.
Migrate fonts, font faces/fallback caches, themes/icons, shader programs/shaders, and every remaining
raw-owning ResourceManager subclass one family at a time. Their self-contained GPU objects retain
the established graphics-thread destruction contract unless a concrete migration requires
otherwise.
For fonts, ownership is separate from rendering policy. Font handles and semantic lookup live in
naturally owned catalogs: application defaults use the default scope, while author `@font-face`
resources are owned by their document scene. Every `ResourceScope` owns an inline `FontService` for
its rendering configuration, emoji fonts, configured fallbacks, and system fallback cache. Fonts
retain only a borrowed service pointer while published in that scope and are detached when removed
or when the scope is destroyed. Raw `Font*` values in text/layout/style structures remain borrowed
views whose enclosing application, scene, theme, or fallback service retains the corresponding
handle. The former process-wide `FontManager` singleton and compatibility namespace were removed.
Publishing a font with an existing local key replaces that catalog binding; the legacy manager
behavior that silently suffixed duplicate font names is intentionally not preserved.
Themes, skins, icon themes, and icons now use shared resource handles. Each scene's
`UIThemeManager` owns its themes and imports their catalogs into that scene's scope; it is not a
process-wide semantic namespace. Shader and shader-program factories return handles, programs own
their constituent shaders, and catalogs/scopes can publish shader programs under semantic keys.
`ShaderProgramRegistry`, `VertexBufferRegistry`, and `FrameBufferRegistry` remain process-wide only
as non-owning inventories of live OpenGL-context objects used for reload and diagnostics.
The raw-owning `ResourceManager<T>` and `ResourceManagerMulti<T>` templates were removed after their
last consumers migrated.
## 11. Required validation matrix
### Ownership and registry
- Unpinned texture expires after its last real owner releases it.
- Region/source retains its texture; atlas retains its sources/textures; framebuffer retains its
attachment.
- Catalog publication retains; erasing one catalog entry does not invalidate other owners.
- Independent catalog/pin leases do not interfere.
- Registry snapshot and UITextureViewer do not retain all resources.
- Registry creation, snapshot, expiration, and purging are race-safe.
### GPU/thread lifetime
- Final texture release on the graphics thread queues rather than immediately deleting.
- Worker-thread final release safely queues; display performs the destruction with a current context.
- Display flushes batches before collecting released textures under the current context.
- Engine shutdown performs a final collection before TextureFactory/Renderer/context destruction.
- No deletion/callback occurs while registry/cache locks are held.
- Repeated test-only Engine creation starts with no prior texture handles or registry state.
- Relevant suites run under TSAN as well as ASAN/LSAN.
### Scope isolation
- Identical keys in sibling scopes resolve independently.
- Explicit imported catalog resolves; missing import does not fall back.
- Global catalog is visible only to scopes importing it.
- Registry records are never semantically resolvable.
- Scope/catalog destruction leaves externally retained resources alive.
- Catalog import order is deterministic and graph cycles are structurally impossible.
### Drawable independence
- One source displayed by two widgets with different tint, alpha, and size.
- Two region instances draw at different sizes without shared mutation.
- State-list and sprite instances maintain independent state/time.
- Nested/reentrant drawing restores nothing because shared objects were not mutated.
- DrawableGroup owns/private-instantiates mutable children.
- Variant copy/move/reset is safe with DrawablePtr.
### Web cache
- Same-origin navigation reuses resources.
- Cross-origin navigation releases only the navigating document lease.
- Two tabs share eligible entries and survive independent navigation.
- Different cookie/auth partitions do not share.
- URI plus different decode/request options produces distinct entries.
- Concurrent requests coalesce while subscribers retain independent generation guards.
- Failed requests retry according to explicit policy.
- TTL uses a monotonic clock; byte-budget eviction works independently.
### Teardown and repeatable tests
- Pending HTTP, decode, and upload work during scene and Engine destruction.
- No destructor recreates Engine, Renderer, TextureFactory, or another manager.
- Repeated Engine create/destroy cycles in one process.
- Each unit test releases its scopes, catalogs, leases, and resources independently.
- Test teardown reports unintended catalog pins and live resource provenance.
- `EE_MEMORY_MANAGER`, supported debug/release configurations, static builds, and shared-library
builds.
## 12. Next implementation deliverable
The ownership migration is complete through Stage 7. Follow-up work is validation and API polish:
expand scene-lifetime and repeated-Engine coverage, audit public ownership documentation, add
debug-only borrowed GPU lifetime checks, audit non-trivial static initialization, and rename
compatibility-era manager filenames when the public include transition is scheduled. OpenGL
context-loss recreation is explicitly outside the supported lifecycle contract.
+4
View File
@@ -46,6 +46,10 @@ Run this **after** all edits and **before** attempting to compile.
## Step 2: Compile the Project
To compile the project in debug mode, execute the `make` command, ensuring you point to the correct directory for your current Operating System.
Always use all processors reported by the platform when selecting the parallel job count. On Linux
and other systems with `nproc`, use `-j$(nproc)` exactly; do not substitute an arbitrary fixed value
such as `-j4`. Use the platform-equivalent processor-count command where `nproc` is unavailable.
The valid OS directory names are: `windows`, `macosx`, `linux`, `bsd`, `haiku`.
Run the following command, replacing `<os_name>` with the correct environment:
+44 -1
View File
@@ -6,7 +6,50 @@ The goal is browser-compatible behavior where implemented, not a parallel eepp-s
## Spec-First Requirement
Agents must implement HTML/CSS features by following the official specifications first. Do not add element-specific hacks just to match one fixture if the behavior belongs to a generic CSS concept.
HTML/CSS compatibility work is **specification implementation**, even when the request begins with a
visual difference in one website fixture. A screenshot identifies a symptom; it does not define the
layout rule. Agents must not patch toward fixture pixels until they can name the generic HTML/CSS
mechanism that produces those pixels.
### Mandatory Decision Process
Before editing layout or rendering code, write down in the working notes or plan:
1. The generic concept involved, such as inline baseline calculation, margin collapsing, intrinsic
sizing, replaced elements, float exclusion, or border painting.
2. The specification section or browser-defined user-agent behavior that governs it.
3. The engine abstraction that owns the behavior. Prefer parsers, computed style, formatting
contexts, line layout, or generic painting over element classes and fixture code.
4. A fixture-independent invariant that a focused test can assert.
If those four points cannot be identified, continue investigating before implementing a fix.
### Prohibited Fix Patterns
Do not put website names, fixture IDs/classes, DOM ancestry from one page, or special coordinates
from a screenshot into generic layout/rendering code. Do not change an HTML element default when the
problem belongs to a CSS formatting rule. Do not introduce unexplained pixel offsets, font-size
multipliers, or thresholds chosen only because they improve a golden image.
Element-specific behavior is allowed only when HTML or a user-agent stylesheet actually defines
behavior for that element, such as replaced form controls or `summary { display: list-item; }`. In
that case, document the HTML/UA rule being implemented; the triggering website is still not the
justification.
### Required Evidence Before Completion
- Add a focused regression test that does not depend on the original website and expresses the
generic invariant. Keep the real fixture or golden test as integration coverage, not as the only
proof.
- Explain every new layout constant from a specification, font metric, existing engine convention,
or documented user-agent choice. If it has no principled source, do not add it.
- Search the generic implementation diff for fixture names, selectors, and unexplained constants.
- In the final handoff, state the governing concept, the abstraction where it was fixed, and why the
solution applies beyond the original fixture.
A visually improved fixture is not sufficient evidence of correctness. If the focused generic test
and the fixture disagree, investigate the model or document an intentional unsupported subset rather
than adding a second compensating adjustment.
Primary references:
+7
View File
@@ -16,3 +16,10 @@ When working on this project, rely on the following resources to understand exis
* **Basic Documentation:** Found in `docs/articles/`.
* **Implementation Examples:** A wide variety of examples showing how to use the library are located in `src/examples/`.
* **General Context:** The `README.md` at the root directory contains deeper project details.
## C++ Virtual Method Style
Follow the convention already used by the class being edited. In particular, when a class declares
virtual methods without the `override` specifier, do not introduce `override` on new methods in that
class. Mixing the styles can enable Clang's inconsistent-missing-override warnings for the existing
declarations. A class-wide conversion is a separate change and must update all applicable methods
together.