Commit graph

34 commits

Author SHA1 Message Date
Erik
55bfd9ca82 feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation
Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 17:22:06 +02:00
Erik
cb4703e8d5 fix(chargen): CC1 review fix round — Custom is template 0, SkillTable cost fallback, frozen model
Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):

- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
  ChargenTemplate entry and cited two nonexistent addresses. Verified
  against the named retail decomp: gmCGProfessionPage::UpdateProfession
  @ 0x004821b0 resolves BOTH the highlighted button and the description
  string from CharGenState.template_ 0..6, and case 0 is button
  0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
  "Adventurer" row CC1 already found sitting at the attribute floor).
  CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
  Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
  so selecting Custom resets the sliders/skills to that row rather than
  leaving them untouched.

- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
  (ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
  0x005C26D0/0x005C27D0 fall through to the global SkillTable,
  portal.dat 0x0E000004, on a heritage-list miss — confirmed against
  ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
  also projects the global SkillTable into
  ChargenOptions.GlobalSkillCostsBySkillId, and
  ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
  heritage list first and the global list on a miss. Added an
  installed-DAT completeness assertion recording reality: the global
  table prices 38/54 advancement skill ids, every one of the 13
  installed heritages ships exactly one heritage-specific override
  (always also priced globally), and 16 ids are genuinely uncostable in
  both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
  override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
  — a retail-legal build may be rejected by local ACE at the CC7
  connected gate; that is an ACE bug, not an acdream defect.

- F3 (MEDIUM): every collection ChargenTableReader hands into the
  record model is now frozen at projection (ToFrozenDictionary/ToArray,
  matching MagicCatalog's house pattern), including both
  ChargenOptions.Empty dictionaries.

- F4 (LOW): added a reflection guard test
  (ChargenNoChoriziteLeakTests) that walks every public
  AcDream.Core.CharGen member (property/indexer/constructor/method
  types, recursively through generic arguments) and fails if any
  resolves to the DatReaderWriter or a Chorizite* assembly.

- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
  states precisely what the installed-DAT gate proves (an OR across
  eight lists, for at least one gender per heritage) rather than the
  stronger claim it previously made, and explicitly calls out the three
  omitted color lists. Added a second installed-DAT gate that records
  per-list reality across every gender of every heritage — found
  complete, no empty lists anywhere in the installed DAT today.

- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
  [MaybeNullWhen(false)] instead of null! suppression, matching the
  house pattern already used elsewhere in the test suite. Fixed every
  call site this surfaced (more than the five originally estimated,
  since Content.Tests has TreatWarningsAsErrors).

Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:33:37 +02:00
Erik
0445004164 feat(content): Campaign CC CC1 — chargen table reader and typed options model
Adds the CC1 data layer for Campaign CC (retail character creation):
a reader for portal.dat's CharGen table (0x0E000002) plus a
presentation-free, Chorizite-free typed options model, and the pure
attribute/skill credit math the later CC3 Runtime owner needs.

Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas +
  HeritageGroups)
- HeritageGroup_CG::Serialize @ 0x005C2100
- Sex_CG::Serialize @ 0x005C1600
- Template_CG::Serialize @ 0x005C0450
- CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider
  setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits
  - (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the
  budget the six RAW attribute values must fit, not points above the floor.
- CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100.
- gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when
  remainingAtrbCredits > 0 (attributes only — skill credits are never
  gated to zero, confirmed by reading the function body).
- CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of
  NormalCost/PrimaryCost is charged per Trained/Specialized skill.
- gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd
  (Olthoi/OlthoiAcid) camera-offset branch CC6 will need.

Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and
ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders
(same field order, different byte format) and ACE.Entity.Enum.HeritageGroup
/ SkillAdvancementClass for the two small stable enums the model exposes.

src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas +
HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc
+ every appearance-option list: hair styles/colors, eye colors, eye/nose/
mouth strips, headgear/shirt/pants/footwear, clothing colors),
ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap
shape, mirrors PaletteOverride's presentation-free pattern), and the pure
math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and
ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus
ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved
slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything
but exactly 55 entries.

src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite
DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite
type crosses into ChargenOptions.

Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the
Content projector (Content.Tests), plus six installed-DAT gate tests
(ContentConformanceDats pattern) against the real portal.dat: 13 heritage
groups (11 standard + 2 Olthoi), the four named heritages with retail
display names incl. "Gharu'ndim", every heritage has a gender with
non-empty appearance option lists, every template's attributes stay in
10..100 and never exceed its heritage's budget (discovered live: NOT every
template fully spends it — each human heritage's "Adventurer" template
sits at the floor as retail's real-DAT-backed "Custom" starting point),
start-area indices resolve into the shared list, and skill costs key to
valid 1..54 wire ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:53:50 +02:00
Erik
9671af0273 fix(physics): S2 — static publication emits authored Spheres as Spheres (AP-155 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Both static sites (LandblockPhysicsPublisher, the headless-only
LandblockPhysicsContentBuilder) emitted an authored Setup Sphere as a
base-anchored Cylinder of radius r and height 2r. The live path emits a
Sphere for the same data, so the same object collided differently by
arrival route, and the narrow phase met a flat cap where retail meets a
curved surface. Both sites now mirror ShadowShapeBuilder.FromSetup's
Sphere block exactly.

Combined Opus review: PASS. Its numeric verification of the dispatch
test's geometry (head-sphere clearance 0.201 m for the true sphere; the
cylinder counterfactual inside by 0.10 m XY with the Z band overlapping)
is what makes the discrimination claim more than a sabotage anecdote,
and its F8 finding is applied: the test now carries a POSITIVE control —
aiming straight through the boulder's centre must block — so a
membership/seed regression can no longer masquerade as a curve-hit
pass. F4 applied: CylHeight is asserted, not inferred (the C4 lesson).
F3 applied: the deleted Quaternion.Inverse base composition is recorded
as internally coherent for the old cylinder's world-Z axis — the defect
was the shape TYPE, not that rotation math.

The review also verified the deleted-cylinder blast radius: the F2
overlay's drawn span is IDENTICAL for both shapes (old [c-r, c+r], new
[c-r, c+r]); the flood sphere's centre rises by exactly r, which cannot
change outdoor membership (XY rectangle) and lands the indoor half on
Session B's dungeon gate alongside S1B; and the sphere-branch flood is
now pinned uncapped by a genuine eleventh-shape A/B test.
PublishStaticCollision — the headless static path — gains its first
test ever.

AP-155 is NARROWED, not deleted (review F13): the has-BSP source split
(entity.MeshRefs vs setup.Parts + AnimPartChanged) survives and keeps
the row active. The shared-primitive-emitter refactor that would make
route independence a compile-time property is the filed follow-up
(review F20).

Population: 3,506 of 5,935 installed Setups, structurally equal to
AP-157's third-branch count (byte-identical classifier — three
independent routes agree: 3,605 - 99 = 3,506).

Clean-room suite at implementation: 11,253 passed / 6 skipped / 0
failed on landed S1B. Post-review-hardening: Publisher tests 25/25,
Content tests 2/2, both green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:07:02 +02:00
Erik
13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00
Erik
e6457cc849 fix(physics): close the AP-156 fix review — real containment oracle, type-level invariant, AP-158
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).

R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.

R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.

A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.

A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.

Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.

LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.

Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:44:48 +02:00
Erik
b52967def3 fix(physics): AP-156 — flood the BSP sphere where the geometry is, not at the part origin
The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md)
FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took
each physics-BSP part's ROOT BOUNDING SPHERE RADIUS
(FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and
centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root
sphere's own Origin.

Re-measured independently against the installed client_portal.dat, reproducing
the reviewer's numbers exactly: 376 of 973 physics-BSP parts have
|origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted
flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73
CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup
0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and
for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one.
Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100
candidate through FindTransitCellsSphere), so a tall prop or door slab was
absent from EnvCells it physically occupies and therefore never a broadphase
candidate there (TransitionTypes.cs:3763 iterates only entries already in the
cell). That is the #98 / #168 class AP-152 exists to remove.

Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH,
CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved
back through named-retail/symbols.json:

  CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as
  CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and
  acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp).

  BSPTREE::GetSphere @0x005397e0
    8b01        mov eax,[ecx]     ; BSPTREE::root_node
    83c004      add eax,4         ; past BSPNODE::vfptr -> CSphere sphere
  So retail's per-part flood sphere IS the BSP root bounding sphere,
  ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... },
  CSphere { Vector3 center; float radius; } -> radius at +0xc).

  CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and
  then walks the PART ARRAY: 0x00511012 call 0x518160
  (CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with
  (num_parts, parts, cellarray). Its EnvCell body,
  CEnvCell::find_transit_cells @0x0052cae0:
    0x0052cb31  mov edx,[eax+0x20]   ; CPhysicsPart::gfxobj (CGfxObj**)
    0x0052cb36  mov esi,[ecx+0x74]   ; physics_sphere (else +0x90 drawing)
    0x0052cb4c  add eax,0x30         ; CPhysicsPart::pos
    0x0052cb5a  call Position::localtolocal   ; transform the sphere CENTRE
    0x0052cb65  fadd [esi+0xc]       ; only NOW the radius
  Retail transforms the centre through the part's own Position before it ever
  touches the radius. Carrying the radius alone is not an approximation of
  that; it is a different sphere.

Changes:

* `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the
  shape's own local frame, scaled like LocalPosition and Radius. Zero for
  Cylinder/Sphere shapes, whose LocalPosition already IS their centre.

* `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that
  supplies radius AND centre from ONE call, replacing the placeholder radius
  plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a
  single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate
  from it, so the gate and the geometry cannot disagree and the radius cannot
  be taken while the origin is dropped. That split is what produced this bug;
  it no longer exists.

* `FromLandblockBspParts` carries the centre too. A landblock-baked part array
  is the same CPartArray walk, so stair runs, fences and rock clusters had the
  identical defect. Both storage forms (flat BSP and the graph fallback) are
  covered.

* `BuildFloodSpheres` places each sphere at
  partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the
  ShadowEntry rows are.

* The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp
  is inside CObjCell::find_cell_list @0x0052b9f0
  (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and
  the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups
  carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their
  tail parts were dropped from the flood entirely. Without this the new
  containment assertion would have covered shapes production never floods
  from.

Register. AP-155 was two divergences with different code paths, populations
and gates under one id; it is NARROWED to its static-publication half and its
flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b)
recorded the approximation as over-inclusive — "floods MORE cells rather than
fewer, the safe direction for membership" — and that false direction was the
stated reason the residual was safe to defer. It was under-inclusive for 170
of 172. AP-156 records the correction, this fix, and the one genuine residual:
acdream's sphere-vs-portal traversal where retail walks each part's sphere
against the cell's own portal planes. AP-155(b)'s "acdream approximates
retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box.
AP-157 filed for the review's F4: retail's third branch floods from ONE
CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 =
CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one)
where acdream floods from every Sphere shape, and acdream's cylinder flood
ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint
population, different live gate. Active AP rows 107 -> 109, literal count.

Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f
at LocalPosition = Zero — the one configuration in which the defect cannot
appear. Every fixture is now off-centre by default, and
`FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production
`physicsBspBounds` seam instead of hand-substituting. Five new facts: the
flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by
the part rotation; it caps cylspheres at ten but never the BSP parts; the
landblock path carries the scaled centre in both storage forms; and an
installed-DAT containment sweep asserting every emitted BSP flood sphere
contains that part's real bounding sphere at entity scale 1.75, behind four
external controls — 973 parts, 376 off-centre, 172 affected, and 170
would-fail-if-the-origin-were-discarded, the last of which fails if the
population ever stops exercising the field.

Nine sabotages, each reverted and re-verified:
  A drop BoundsCenter from the flood       -> 3 Core
  B rotate by entity rot, not part rot     -> 1 Core (the rotation fact only)
  C FromSetup discards the origin          -> 1 Core + 2 App + 1 Content
     (the shipped defect, now caught in three projects)
  D drop entScale on BoundsCenter          -> 2 App + 1 Content
  E landblock flat branch drops the centre -> 1 Core
  F landblock graph branch drops it        -> 1 Core
  G drop partScale on the landblock centre -> 1 Core
  H re-apply the 10-cap to every branch    -> 1 Core
  I remove the cylsphere cap               -> 1 Core
AP-152's own two sabotages re-run against this tree: the step-0 gate disabled
still reddens exactly its five facts with Headless 89/89 green, and
cylinder-first flooding still reddens exactly one.

Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing
warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the
11,203 baseline at 4abd1b5e — Core 4264 -> 4268, Content 126 -> 127, App
unchanged (one rename, not an addition). No new skips.

NOT yet gated live. This moves shadow-cell membership for real objects, in
both directions, and the connected session must look for both: props and doors
that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups),
AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink
below their pre-4abd1b5e size, which is the regression this fixes). Tall
indoor props and door slabs — the ones whose sphere sits metres above the part
origin — are where the change is largest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:52:04 +02:00
Erik
4abd1b5eb7 fix(physics): AP-152 — dispatch collision shapes BSP-first, at emission and at the cell flood
The register row predicted "catching or stopping on a doorway sill". That
symptom could not have been occurring. `Transition.BspOnlyDispatch`
(TransitionTypes.cs:1348, landed 2026-05-25 as A6.P7) already skipped both
primitive branches (:3911, :3954) whenever the target's wire PhysicsState
carries HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP
for every affected Setup. The extra primitive was never tested for collision.

The live defect was CELL MEMBERSHIP. The same shape list feeds
`ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and
preferred Cylinders over everything whenever any Cylinder existed — retail's
SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups
acdream therefore flooded shadow cells from the cylinder and never from the
slab: an object absent from cells it physically occupies, which is the
#98 / #168 symptom class, not the door-collision class the row named.

Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than
taken from Binary Ninja, which drops flag tests:

  CPhysicsObj::FindObjCollisions @0x0050f050
    0x0050f165  test dword [esi+0xa8], 0x10000
    0x0050f16f  je   0x50f1a2        ; clear -> primitive dispatch
    0x0050f18d  call 0x518180        ; CPartArray::FindObjCollisions
    0x0050f19d  jmp  0x50f2b0        ; UNCONDITIONAL, past BOTH primitive loops
                                     ; (CylSphere 0x50f1a2, Sphere 0x50f21d)
    0x0050f1d6  jae  0x50f317        ; CylSphere loop exhausted -> RETURN
    0x0050f22f  je   0x50f31b        ; zero Spheres -> RETURN seeded OK_TS

  CPhysicsObj::calc_cross_cells @0x00515230
    0x00515285  test dword [esi+0xa8], 0x10000
    0x0051528f  jne  0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0
    0x005152d1  call 0x52b9f0        ; cylsphere branch, below the jump
    0x005152fb  call 0x52b990        ; sorting-sphere branch, below the jump

Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins.
Every address above was resolved back to its symbol by exact lookup in
named-retail/symbols.json.

Changes:

* `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2
  are skipped entirely when any part's EFFECTIVE GfxObj carries a physics
  BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so
  they cannot read different identities — a gate on `setup.Parts` would,
  after an ObjDesc swap, suppress the primitives while step 3 emitted
  nothing and `Build` returned null, deleting the entity's collision.
  Emission order is unchanged. This also removes acdream's undeclared
  reliance on the server sending the flag: the gate is derived from the
  parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it.

* `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells'
  own order: BSP, else Cylinder, else everything. Given the gate above this
  is a no-op for every shape list acdream produces (FromSetup is now
  exclusive; both landblock-static publishers already emit homogeneous
  lists), so the measured membership delta remains attributable to the
  gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail
  genuinely dispatches here, and it guards a future additive producer.

`Transition.BspOnlyDispatch` is deliberately untouched.

Register: AP-152 RETIRED with its four false statements corrected — the risk
statement (the symptom was already inert); "small and centred at the part
origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is
(0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is
0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision
geometry); and naming one pinning test where two existed. AP-153/154/155
filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e
where acdream's gate is live; the query-time guard takes a client-derived
flag off the wire; and the static publishers emit Setup Spheres as
height-capped Cylinders while BuildFloodSpheres approximates retail's
bounding box with bounding spheres.

Tests. Both pinning tests corrected, neither deleted:
`FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`;
`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on
`_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups.
`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's
eighth green test covering nothing — its assertions sat inside
`if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only
`Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale
deleted, the old body passes and the corrected body fails. Three new facts:
the effective-identity gate, the App-layer CylSphere+BSP registration (no
App fixture combined the two before), and the flood-set dispatch. One new
installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99
Sphere+BSP) behind external bucket controls, re-measured independently and
agreeing exactly with the filing commit's separate sweep.

All eight sabotages run and reported; every discriminating fact reddens in
the intended direction and only there. Clean Release build after deleting
every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0
failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added
facts, no new skips.

Blast radius, corrected: the FromSetup half is graphical-only (its sole
production caller is LiveEntityCollisionBuilder in AcDream.App, which
AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content).
The BuildFloodSpheres half lives in AcDream.Core and DOES execute in
Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there
because both of that builder's registrations pass homogeneous lists.
Headless suite green at 89/89.

NOT yet gated live: this changes shadow-cell membership for 22 Setups used
by 151 Door weenies and 38 stationary props. Needs a connected session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:51:36 +02:00
Erik
619de97ad1 fix(test): evaluate BOTH deleted guards; correct AP-22's overstated coverage claim
From the AP-22 dual review (both lenses PASS). No production change.

THE RECORD WAS WRONG. bc4679cd claimed "Headless.Tests 89/89 exercises the
site-3 copy". The architecture review disproved it by sabotage: restoring the
invented cylinder in BOTH static sites left the entire suite green. No test
anywhere references PublishStaticCollision, and the headless suite's dummy DAT
proxy makes LandblockLoader.Load fail for every landblock, so CreatePublication
returns before reaching it. Two of the three deletions — including the
headless-only one — are pinned by the installed-DAT reachability proof ALONE.
The deletion is still correct; the evidence claim was not, and a successor
trusting it would think those sites had regression cover they do not have.

THE TEST NOW COVERS WHAT IT CLAIMED. Its comment said "the exact guard the
three deleted copies used", but site 1 guarded on `Radius > 0.0001f` while
sites 2 and 3 used the strictly wider `Radius > 0f`. Those are not the same
predicate: the review measured that they differ over the installed DAT by
exactly one Setup, 0x02001657, whose radius is the denormal 1.3e-39. The test
now evaluates BOTH and asserts each is empty, so the wider guard the
headless-reachable deletion actually used is no longer asserted by proxy.

Sabotage-verified: widening the new guard to `>= 0f` reddens it (1,652
zero-radius Setups appear), so the assertion is live rather than vacuously
empty over real DAT data.

AP-22's row also corrected for two precisions the reviews surfaced: the
load-bearing fact is that all 1,652 no-primitive Setups carry Radius exactly 0
(not the 1,294 first cited), and retail's `report_object_collision` DOES read
GetHeight for the quadrant field — recorded so a future reader does not mistake
it for a refutation of "never collision geometry", which is a claim about
FindObjCollisions' shape dispatch only.

Reachability now independently reproduced by four decoders — the contract's
sweep, the implementer's parser, and both reviewers' from-scratch parsers —
plus tools/SetupInspect agreeing bit-for-bit on the cited ids.

Content.Tests 125/125. No new skips; #302/#308/#321 did not fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 08:54:31 +02:00
Erik
bc4679cda5 fix(physics): delete the invented Setup-radius collision cylinder (AP-22)
Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.

CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.

THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.

The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.

Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
  claim plus five EXTERNAL positive controls, so a broken enumeration cannot
  satisfy it vacuously. Inverting the claim reddens it; emptying the
  enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
  deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
  PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
  fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
  Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
  the landblock id.

Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.

AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.

Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 08:27:26 +02:00
Erik
dc0468cc2b fix(tests): replace sleep-race concurrency proofs in RetailDatLoaderTests
Two tests proved "these two unrelated DAT reads ran concurrently" by
racing a fixed Thread.Sleep(40) window against .NET thread-pool
scheduling latency for a second Task.Run. Under the CPU contention of
a full `dotnet test AcDream.slnx` run (all 9 test projects' VSTest
hosts launch concurrently) plus a busy machine, thread-pool injection
can occasionally miss the window, making MaxConcurrentReads read 1
instead of 2 and failing the assertion with no underlying code defect.

RetailAnimationLoader and RetailPhysicsScriptLoader both coalesce
same-key reads correctly via ConcurrentDictionary<K, Lazy<T>>.GetOrAdd,
which is atomic and timing-independent (verified by reading, not just
running) - only the test's method of proving cross-key overlap was
timing-fragile. DecodedTextureCacheTests already uses the correct
deterministic-gate pattern; this brings RetailDatLoaderTests in line
with it via a Barrier-backed rendezvous instead of a sleep race.

Filed as #248 (docs/ISSUES.md) with the full attempt matrix: could not
catch the originally-reported AcDream.Content.Tests failure in the act
despite ~72 Content.Tests executions across four contention strategies
over ~30 full-suite-equivalent runs, though the general mechanism
reproduced 3x in AcDream.App.Tests's already-known zero-allocation
flake class (left untouched, out of scope here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:03:03 +02:00
Erik
c7861020e1 test(content): give the loader concurrency tests real concurrency
Fixing the two failures that were stopping portable-headless earlier let
the job reach AcDream.Content.Tests for the first time on either
operating system - the test loop exits on the first failing project, so
the windows leg had never got past the apt step and the ubuntu leg had
never got past Core.Net. Two RetailDatLoaderTests cases were waiting
there, and they failed on both.

Both assert on RawDatabase.MaxConcurrentReads after issuing two Task.Run
reads that each block 40 ms in Thread.Sleep. A pair of pool work items is
not a guarantee of two workers in flight: on a low-core or saturated pool
the second queues behind the first, the reads run back to back,
MaxConcurrentReads stays 1, and the assertion fails for a reason that has
nothing to do with the loader. Pinning the suite to two CPUs on Ubuntu
reproduces it 5 times in 6; Windows is clean 6 of 6 at sixteen cores,
which is why nobody had seen it.

The pairs now start with TaskCreationOptions.LongRunning on the default
scheduler, which asks for a thread each. No assertion is changed - they
still fail if the loader serialises. The two coalescing cases moved onto
the same helper on purpose: two callers genuinely in flight is the
situation coalescing exists for, and a sequential pair was only ever
exercising a cache hit. Ten of ten clean under the same pin.

Release build green. App tests 4,152 / 3 skipped. Content 124 / 124.
Filed as #255.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:52:53 +02:00
Erik
9569dadb57 feat(headless): share immutable gameplay content 2026-07-27 09:00:48 +02:00
Erik
9cd42417a8 feat(content): bake and read flat collision assets
Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
2026-07-25 15:22:08 +02:00
Erik
f2644d42c2 perf(render): bound animation and alpha scratch residency
Complete Slice D3 by replacing the unbounded animation dictionary with a concurrent byte/count LRU and by putting the three retail alpha scratch owners behind one typed aggregate budget. Preserve immediate growth and draw order while reclaiming one-frame density spikes after sustained under-use. Close stale bounds-cache issue evidence without inventing a cache.
2026-07-24 16:34:28 +02:00
Erik
230a7df454 perf(content): remove exception-driven setup probes 2026-07-24 15:12:21 +02:00
Erik
f05afc07c1 perf(render): consume prepared mesh package at runtime 2026-07-24 15:07:25 +02:00
Erik
c42f93b323 perf(content): add typed prepared asset source
Introduce explicit loaded, missing, and corrupt outcomes over typed pak keys, validate package/DAT identity at open, add zero-I/O TOC probes, preserve cancellation, and route the package path through RuntimeOptions. Production injection follows in the next checkpoint.
2026-07-24 14:52:12 +02:00
Erik
999201cca7 feat(bake): publish validated pak artifacts atomically 2026-07-24 13:40:58 +02:00
Erik
0dee14765b feat(content): add physical blob aliases to pak writer 2026-07-24 13:33:53 +02:00
Erik
7b456b49d6 perf(diag): per-frame history export + checkpoint LOH/cache counters + soak capped mode (2026-07-24 audit review)
An adversarial performance review found our own instruments cannot
measure the project's own performance gates:

- FrameProfiler aggregated CPU/GPU/alloc/stage samples into ~5-second
  windows and reset the ring buffers after each report, so route-wide
  p50/p95/p99 distributions across a whole soak could not be
  reconstructed after the fact. ACDREAM_FRAME_HISTORY=<path> now opts
  into a separate per-frame history (one record per frame, ~72
  bytes/record, accumulated in memory with zero frame-thread I/O) that
  a shutdown-only Dispose() writes as CSV. The aggregated [frame-prof]
  report format and its existing metrics are unchanged.

- The canonical checkpoint JSON tracked cache residency (entry/byte
  counts) but never LOH size/fragmentation, process-wide allocated
  bytes, or cache hit/miss/eviction traffic — a committed audit JSON
  showed 65% LOH fragmentation that no tracked instrument recorded,
  and "does a revisit portal hit or miss the caches" was unanswerable
  from an artifact alone. WorldLifecycleResourceSnapshot now carries
  loh_size_bytes/loh_fragmentation_bytes (GCMemoryInfo.GenerationInfo
  index 3), process_total_allocated_bytes (GC.GetTotalAllocatedBytes),
  and Interlocked hit/miss/eviction counters for the CPU mesh cache,
  decoded-texture cache, and the four bounded DAT-object caches
  (portal/cell/highRes/language, aggregated).

- run-connected-r6-soak.ps1 unconditionally forced
  ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while its sibling
  lifecycle-gate script correctly gated it behind a switch. Added
  -Uncapped (default capped, matching the sibling script's pattern),
  fixed the stationary dwell (12s -> 26s, past the 25s
  LiveEntityLivenessController deadline the adjacent comment already
  cited), and now write an env-disclosure.json into the automation
  artifact directory before every launch listing every ACDREAM_* var
  the script sets plus -Uncapped, since the prior audit could only see
  ACDREAM_DUMP_MOVE_TRUTH and nothing else was ever recorded anywhere.

Cache counters are wired via the existing composition path
(ObjectMeshManager already owns the CPU mesh cache and the mesh
extractor directly; content.Dats is threaded into
WorldLifecycleResourceSnapshotSource the same way every other
composition consumer receives it). The DAT-object cache lives behind
IDatReaderWriter, a third-party interface from the DatReaderWriter
package that cannot be extended; RuntimeDatCollection (the one
production implementation) exposes the aggregate stats directly and a
pattern match reads them, degrading to zero for any test double —
no new static registry was introduced (GpuMemoryTracker remains the
one precedented process-wide static).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1da2c33c875b41fa383dd79694ee2765f0e21896)
2026-07-24 12:00:30 +02:00
Erik
60bc313917 perf(content): CellStruct clone guard, solid-color texture cache, drop double ToList (2026-07-24 audit review)
Three small verified MeshExtractor.cs fixes from an allocation audit:

- CellStruct clip-map clone guard: the CellStruct polygon-surface path
  cloned decoded texture data unconditionally before applying clip-map
  transparency, even though the comment above it says "if we got this
  from the cache, we need to clone it." The GfxObj path already had the
  correct pattern (clone only when textureDataIsCached). Wired the same
  textureDataIsCached flag through CellStruct's TryGet/GetOrCreate/
  RetainOrUse call sites and gated the clone on it, matching GfxObj
  exactly — freshly-decoded arrays (the common case) now skip a
  redundant clone + BlockCopy.

- Solid-color texture cache: isSolid surfaces (Base1Solid / NoPos-
  stippled polys) allocated a fresh 32x32 RGBA array (4 KiB) on every
  polygon, even for repeated colors. Added a MeshExtractor-scoped
  ConcurrentDictionary<uint, byte[]> keyed by packed ARGB, bounded by
  distinct colors observed (a few hundred at most) rather than call
  volume. Traced every downstream consumer of the solid-color array
  (clip-map clone-guard, translucency clone-guard, GL upload, pak
  serialization) — none currently mutate a solid-color array in place,
  since isSolid is mutually exclusive with the texture-decode branch
  those guards live in — but wired textureDataIsCached = true at both
  call sites anyway so the existing clone-before-mutate machinery
  protects the shared array if that ever changes.

- Dropped the redundant .ToList() on both _dats.ResolveId(...) calls:
  DatCollectionAdapter.ResolveId already returns a materialized List<T>
  typed as IEnumerable; the immediate .OrderByDescending().FirstOrDefault()
  enumerates once, so the extra copy was pure waste.

Added SolidColorTextureCacheTests (dat-gated, matching suite convention)
exercising the new cache directly via internal visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e9f9d7539966cc26343e45d653a751c5d1045810)
2026-07-24 11:49:46 +02:00
Erik
749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00
Erik
96ddfdf175 feat(vfx): port retail effect scheduling and delivery 2026-07-14 09:25:44 +02:00
Erik
363e046112 feat(vfx): decode retail hooks and typed tables
Replace the incomplete package path with one DatCollection-backed compatibility seam for PhysicsScripts and Animations. Preserve CreateBlockingParticle's inherited payload and following cursor, route every production and audit consumer through the corrected loaders, and apply retail's post-UnPack StartTime ordering.

Add exact stored-order PhysicsScriptTable upper-threshold resolution, high-byte DID and embedded-ID validation, plus live effect profiles with Setup-to-PhysicsDesc precedence across top-level and attached entity lifetimes. Keep blocking execution deferred and narrow TS-11 accordingly.

Pin synthetic malformed/cursor/order fixtures, installed-DAT blocking and recall audits, high-index IDs, IEEE boundaries, profile teardown, and ordinary decoder parity; synchronize architecture, inventory, milestones, roadmap, research, and memory.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 08:17:44 +02:00
Erik
d53fe30ffe research(vfx): pin retail projectile and effect oracle
Establish the executable-backed PhysicsDesc, sequence-gate, PhysicsScript, CreateBlocking, particle-anchor, projectile, and Hidden-state behavior before changing runtime code. Correct stale blocking/threshold claims and synchronize the project instructions with the current UI architecture and matching retail binary.

Add copyright-safe packet and DAT-container fixtures plus a failing installed-DAT conformance audit for projectile shapes, typed tables, recall motion, default scripts, and raw CreateBlocking inventory.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 22:40:42 +02:00
Erik
859cf5ec02 refactor(pipeline): MP1b review - unify DatCollectionAdapter + TOC/log test gaps
Adversarially-verified review findings 7 and 8:

(7) The DatCollection->IDatReaderWriter adapter existed as THREE
near-identical copies (App-internal original, Bake's copy, Content.
Tests' copy) — a structure where adapter drift is exactly what the
live-vs-pak equivalence suite cannot detect (both sides would only
drift together if they shared one implementation). Now ONE public
AcDream.Content.DatCollectionAdapter next to IDatReaderWriter (GL-free
home established in MP1a), carrying App's FULL behavior including the
[dat-miss] TryGet tripwire log (which now also covers the bake tool
and the equivalence suite) and the caching/locking. All three copies
deleted; WbMeshAdapter (App), BakeRunner (Bake), and
PakEquivalenceTests (Content.Tests) resolve the shared class.
Iteration properties return the REAL dat iterations — the App copy's
hardcoded 0 was a stub nothing read; the unification intentionally
keeps truth (noted in the doc comment). Verified post-move: no
Silk.NET anywhere in Content / Bake / Content.Tests / Bake.Tests
resolved dependency graphs.

(8) Two test gaps closed in PakRoundTripTests: (a) direct on-disk TOC
sortedness — blobs added in DESCENDING key order, then the raw file
bytes parsed (not through the reader) and every TOC entry asserted
strictly ascending; (b) corrupt-blob logging — five repeated reads
through both public paths (TryReadObjectMeshData + ContainsKey) with
stderr captured, asserting exactly ONE [pak-corrupt] line for the
victim key.

Full suite: 4120 tests, 0 failures (Content.Tests 56, Bake.Tests 1,
plus the pre-existing 4 skips).
2026-07-05 22:18:30 +02:00
Erik
86e0dc4655 fix(pipeline): MP1b review - bake CLI determinism + bounded memory
Adversarially-verified review findings 1 and 10:

(1) The bake pipeline no longer accumulates every decoded ObjectMeshData
in one ConcurrentBag before writing (multi-GB OOM risk on the full
bake), and no longer writes blobs in thread-completion order (which
violated the plan's "bakes must be byte-reproducible run-to-run").
New shape in BakeRunner: build the FULL id list, sort by PakKey, chunk
into 512-id batches; Parallel.ForEach WITHIN each batch; sort each
batch's results by key and AddBlob sequentially; release the batch.
Batches are contiguous key ranges, so the blob region lands in global
key order regardless of thread scheduling, and peak memory is one
batch's output. Side-staged particle-preload meshes drain per batch
into a key-deduped map (first instance wins — per-id extraction output
is deterministic, so instance choice cannot affect bytes) and are
written after all batches, sorted by key, skipping keys already
written.

Program.cs is now a thin arg-parsing shell over the public BakeRunner
so the new dat-gated byte-reproducibility test can drive the REAL
pipeline: tests/AcDream.Bake.Tests (new project, rule 6; registered in
slnx; no Silk.NET in its resolved dependency graph — verified) bakes
the same 9-id mixed fixture twice with DIFFERENT thread counts (8 vs
3 — thread scheduling was the nondeterminism source) and asserts the
two pak files are byte-identical. Ran for real against the dats on
this machine: green.

(10) The isSetup argument for EnvCell extraction now matches at both
call sites (BakeRunner and PakEquivalenceTests both pass false) and is
documented at each: the runtime's own request sites
(WbMeshAdapter.IncrementRefCount / EnsureLoaded) pass isSetup: false
for every MeshRef id including cell-geometry ids. The parameter is
currently dead in MeshExtractor.PrepareMeshData (dispatch is on the
resolved dat type), but two disagreeing call sites were a latent trap.

Header FormatVersion is no longer set by the bake (PakWriter stamps it
per review finding 2, previous commit).
2026-07-05 22:14:31 +02:00
Erik
84d1956d84 fix(pipeline): MP1b review - pak format/reader hardening
Adversarially-verified review findings 2,3,4,5,6,9:

(2) PakFormat.CurrentFormatVersion=1 constant; PakWriter stamps it
unconditionally (caller header templates can no longer produce a
version-0 pak — the default-0 footgun); PakReader refuses any other
version with a message naming found/expected. Tests: versions 0 and 2
both rejected; writer stamps even when the template omits the field.

(3) Reader robustness, all under the documented corrupt-=-missing
CONTRACT (external-file input — surface loudly once, then behave as
absent; never garbage, never throw from a lookup): (a) per-TOC-entry
bounds validation at open (offset/length outside [header, toc) or
past EOF -> logged once, entry missing, siblings unaffected); (b)
TryReadObjectMeshData catches deserialization failures (malformed
structure behind a matching CRC) -> logged once per entry, false; (c)
structurally unopenable files throw at OPEN with a clear message:
unfinalized header (tocOffset < header size — the placeholder-header
crash signature) and TOC-past-EOF truncation. Tests: corrupt TOC
entry, truncated pak, half-written pak, malformed-blob-behind-valid-
CRC (tamper + CRC recompute) — each verifying sibling blobs still read.

(4) Single-pass read: TryReadObjectMeshData now does ONE ReadArray out
of the map; CRC and deserialization run over the same buffer (was: a
separate VerifyCrc traversal + a second ReadArray + a ToArray copy
inside Serializer.Read). New Serializer.Read(byte[]) overload avoids
the defensive copy. Verdict set stays a ConcurrentDictionary (MP1c
calls this from 4 decode workers). True span-over-mmap zero-copy is
deferred to MP1c profiling per the class doc comment.

(5) PakWriter.Dispose restored to try/finally: Finish() does real I/O
and can throw (disk full) — the stream must ALWAYS close so no file
handle leaks mid-unwind. (Reverts the a5926ebc simplification, which
was wrong about this.) Test: dispose after an AddBlob exception leaves
the file deletable.

(6) CRC-32 known-answer vectors: "123456789" -> 0xCBF43926, empty ->
0x00000000, single zero byte -> 0xD202EF8D. The suite was previously
blind to a self-consistent-but-wrong CRC.

(9) Equality comparator floats switched to bit-equality
(SingleToInt32Bits/DoubleToInt64Bits, incl. Vector2/3 + Matrix4x4
components): for byte-identity round-trip purposes `==` was both too
strict (NaN==NaN false — a surviving NaN payload would wrongly FAIL)
and too lax (-0.0==+0.0 true — a sign-bit flip would wrongly PASS).

Content.Tests: 54/54 green (was 43).
2026-07-05 22:08:59 +02:00
Erik
55f16a0205 test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated)
PakEquivalenceTests runs MeshExtractor LIVE and bakes the SAME fixture
ids to a temp pak, reads it back via PakReader, and deep-compares every
field via the Task 3 ObjectMeshDataEquality comparator. Since the bake
tool and the live client drive the identical MeshExtractor code (MP1a),
this proves the pak ROUND-TRIP preserves what extraction actually
produces on real content — the serializer's job, not a re-verification
of the extraction algorithm itself (the existing Conformance suite owns
that).

Fixture set (>= the plan's minimums): 10 GfxObjs (3 known-tricky ids
reused from Issue119UpNullGfxObjDumpTests - 0x010002B4, 0x010008A8,
0x010014C3 - plus 7 more from dat order), 3 Setups reused from door/
tower conformance fixtures (0x020019FF, 0x020005D8, 0x020003F2), 5
EnvCells walked from the Holtburg landblock 0xA9B40000's
LandBlockInfo.NumCells range (same idiom as
StipplingSurfaceEquivalenceTests).

Skips cleanly when dats are absent via ContentConformanceDats.
ResolveDatDir(), a deliberate small duplicate of
ConformanceDats.ResolveDatDir()'s exact pattern (env var then
Documents/Asheron's Call fallback) since Content.Tests cannot reference
the AcDream.Core.Tests project. ContentTestDatCollectionAdapter is a
third small copy of the IDatReaderWriter dat-access glue (alongside
AcDream.App's internal original and AcDream.Bake's copy) for the same
layering reason (structure rule 6: tests live in the project matching
the layer under test).

Ran for real against the dats on this machine (not just CI-skip path):
1 test, all fixture ids extracted live with zero failures, baked,
read back, and field-for-field identical. Full solution dotnet test:
4106 tests total (43 Content.Tests + 385 Core.Net.Tests + 425
UI.Abstractions.Tests + 722 App.Tests incl. 2 pre-existing skips + 2531
Core.Tests incl. 2 pre-existing skips) — 0 failures.
2026-07-05 21:33:32 +02:00
Erik
981025d58b feat(pipeline): MP1b - PakWriter + mmap PakReader
TDD: PakRoundTripTests written first, confirmed a compile failure
against the not-yet-existing PakWriter/PakReader types.

PakWriter streams [header placeholder][64-byte-aligned blobs][TOC sorted
by key], then seeks back and finalizes the header once TocOffset/TocCount
are known (plan: "TOC last" so the writer doesn't need blob count
up front). PakReader mmaps the whole file once, loads the TOC into a
sorted array for O(log n) binary-search lookup, and verifies each blob's
CRC-32 LAZILY on first access (cached per-index so a corrupt blob logs
exactly once and is thereafter always treated as missing rather than
handing back garbage bytes). No locks anywhere — the mapped memory is
immutable for the reader's lifetime by construction.

CRC-32 implemented directly (standard IEEE 802.3 table-driven variant)
rather than adding a System.IO.Hashing package reference, keeping
AcDream.Content's dependency surface minimal per its existing
"no GL binaries" intent.

9 tests green: header round-trip, every-key-found, blob deep-equality
via the Task 3 comparator, missing-key returns false, corrupted-blob
(single flipped byte) is detected and treated as missing while leaving
sibling blobs unaffected, 64-byte blob alignment, and TOC binary search
cross-checked against a linear scan for both present and absent keys.
2026-07-05 21:21:58 +02:00
Erik
a5ba435839 feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)
TDD: ObjectMeshDataSerializerTests + the shared ObjectMeshDataEquality
field-by-field comparator (reused by Task 6's equivalence suite) written
first, confirmed a compile failure against the not-yet-existing
ObjectMeshDataSerializer type.

Serializes EVERY field of the ObjectMeshData family per the plan's
normative layout: primitives raw LE, arrays as count:i32+payload,
blittable arrays (VertexPositionNormalTexture[], ushort[], byte[]) via
MemoryMarshal.AsBytes bulk copy, TextureBatches written sorted by the
(Width, Height, Format) key tuple for run-to-run determinism regardless
of dictionary insertion order, nullable fields as present:byte+value.
EnvCellGeometry nests recursively (MeshExtractor can populate one level
today; the serializer supports arbitrary depth rather than assuming it).

Namespace-trap finding: StagedEmitter.Emitter resolves to
DatReaderWriter.DBObjs.ParticleEmitter (the dat DBObj, verified via
reflection against the pinned Chorizite.DatReaderWriter 2.1.7 package
and confirmed live by MeshExtractor's `emitter.HwGfxObjId.DataId` call
site compiling), NOT AcDream.Core.Vfx.ParticleEmitter (the runtime
particle-simulation type with a live Particle[] pool that would NOT be
serializable asset data). All ~31 of its fields are written explicitly
rather than delegating to its own Pack/Unpack, which require a live
DatBinWriter/DatBinReader bound to a DatDatabase — coupling our pak's
determinism to a third-party wire-format helper we don't control the
versioning of.

33 tests green: 9 round-trip fixtures (empty/vertices+indices/multi
texture-batch-groups/setup-parts/emitters/nullable-present/nullable-
absent/edge-lines/nested-EnvCellGeometry), same-instance-twice byte-
identity, and dictionary-insertion-order-independence (two orders ->
identical bytes) plus a key-sort-order assertion on the raw bytes.
2026-07-05 21:19:02 +02:00
Erik
8248abe9d4 feat(pipeline): MP1b - pak key + header/TOC primitives
TDD: PakKeyTests + PakFormatTests written first (confirmed a compile
failure against the not-yet-existing AcDream.Content.Pak namespace),
then PakKey (64-bit type:u8|fileId:u32|reserved:u24 compose/decompose)
and PakFormat (64-byte PakHeader, 24-byte PakTocEntry) implemented to
the normative layout in the MP1b plan. 21 tests green, including a key-
ordering test proving ascending numeric key order equals ascending
(type, fileId) tuple order (the TOC binary-search precondition) and an
explicit byte-offset test for both structs.
2026-07-05 21:14:10 +02:00
Erik
f29255a45c test(pipeline): MP1b - AcDream.Content.Tests scaffold
New xunit test project for the Content layer (rule 6: tests live in the
project matching the layer under test). ProjectReference to
AcDream.Content; TreatWarningsAsErrors + LangVersion latest per MP1b's
csproj requirements. Registered in AcDream.slnx. Placeholder PakKeyTests
smoke test becomes the real Task 2 suite next.
2026-07-05 21:11:12 +02:00